From 47348d13ec4508dcfe440e34c6d511bb02998fb2 Mon Sep 17 00:00:00 2001 From: "grokkybara[bot]" <304785771+grokkybara[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:44:42 +0000 Subject: [PATCH] Synced from monorepo Synced from monorepo Changes: - Report invalid MCP server config instead of failing startup - Keep completed terminal output when the gateway connection is lost - Show a duration-only detail view for single-task task output - Don't let a stale registry turn counter hide local sessions - Raise the file-descriptor soft limit on Linux and log effective limits at startup - Stop aborting when HTTP client construction fails - Make session thread and runtime spawn failures recoverable - Fix main-prompt paste parity in the question freeform input - Fire SessionEnd hooks on /exit and headless quit - Embed the deployment-config signing public key - Repaint paste-chip background on inline panel inputs - Security: prevent acceptEdits from auto-approving agent writes into the always-trusted global hook root - Fix stacked "Worked for" markers so parks render as status and turns close with exactly one marker - Parse hooks from config files - Add a remote kill-switch for managed-config signature verification - Security: fix workspace file-reference resolution bypassing workspace filesystem confinement Source-Revision: d02693a856a54f1030695b36b91d276e96b30b23 --- Cargo.lock | 9 +- SOURCE_REV | 2 +- crates/codegen/xai-grok-agent/src/error.rs | 5 + .../src/plugins/hooks_adapter.rs | 183 +- .../codegen/xai-grok-config-types/src/lib.rs | 3 + .../codegen/xai-grok-config-types/src/mcp.rs | 168 +- crates/codegen/xai-grok-config/src/lib.rs | 14 +- crates/codegen/xai-grok-config/src/loader.rs | 296 +- .../xai-grok-config/src/managed_cache.rs | 2 +- .../src/managed_cache/claim_tests.rs | 43 +- .../src/managed_cache/tests.rs | 283 +- .../xai-grok-config/src/signed_policy.rs | 214 +- .../src/signed_policy/tests.rs | 156 +- crates/codegen/xai-grok-hooks/Cargo.toml | 1 + crates/codegen/xai-grok-hooks/src/config.rs | 655 +++-- .../codegen/xai-grok-hooks/src/discovery.rs | 166 +- .../codegen/xai-grok-hooks/src/dispatcher.rs | 1 + crates/codegen/xai-grok-hooks/src/event.rs | 316 ++- .../xai-grok-hooks/src/runner/command.rs | 7 + .../codegen/xai-grok-hooks/src/runner/http.rs | 2 + crates/codegen/xai-grok-http/src/lib.rs | 19 +- crates/codegen/xai-grok-pager-bin/Cargo.toml | 2 +- crates/codegen/xai-grok-pager-bin/src/main.rs | 31 +- .../xai-grok-pager-minimal/src/live.rs | 15 +- .../xai-grok-pager-minimal/src/overlay.rs | 4 +- .../xai-grok-pager-minimal/src/plan.rs | 4 +- .../xai-grok-pager-pty-harness/src/flows.rs | 41 +- .../xai-grok-pager-pty-harness/src/lib.rs | 4 +- .../xai-grok-pager-pty-harness/src/pty.rs | 3 + .../tests/settings_locked_row_e2e.rs | 245 ++ crates/codegen/xai-grok-pager/Cargo.toml | 2 +- .../xai-grok-pager/docs/custom-hooks.md | 3 + .../docs/user-guide/10-hooks.md | 46 +- .../docs/user-guide/20-background-tasks.md | 8 + crates/codegen/xai-grok-pager/src/acp/mod.rs | 5 + .../codegen/xai-grok-pager/src/acp/spawn.rs | 212 +- .../codegen/xai-grok-pager/src/acp/tracker.rs | 64 +- .../src/app/acp_handler/background.rs | 26 +- .../xai-grok-pager/src/app/acp_handler/mod.rs | 19 +- .../src/app/acp_handler/permissions.rs | 21 +- .../src/app/acp_handler/prompt_origin.rs | 67 +- .../src/app/acp_handler/queue.rs | 5 - .../app/acp_handler/session_notification.rs | 37 +- .../src/app/acp_handler/tests/interjection.rs | 624 +---- .../src/app/acp_handler/tests/mod.rs | 23 +- .../app/acp_handler/tests/turn_completion.rs | 343 ++- .../src/app/agent_view/interactions.rs | 159 +- .../xai-grok-pager/src/app/agent_view/mod.rs | 51 +- .../src/app/agent_view/queue.rs | 205 +- .../src/app/agent_view/render.rs | 10 +- .../src/app/agent_view/session.rs | 3 +- .../xai-grok-pager/src/app/app_view.rs | 11 + .../src/app/dispatch/dashboard.rs | 2 + .../src/app/dispatch/interject.rs | 4 - .../xai-grok-pager/src/app/dispatch/prompt.rs | 3 +- .../xai-grok-pager/src/app/dispatch/queue.rs | 386 +-- .../src/app/dispatch/settings/ui.rs | 5 + .../src/app/dispatch/tests/prompt.rs | 24 +- crates/codegen/xai-grok-pager/src/app/mod.rs | 6 +- .../codegen/xai-grok-pager/src/app/mouse.rs | 19 +- .../xai-grok-pager/src/app/turn_completion.rs | 5 +- .../src/app/turn_completion/tests.rs | 243 +- crates/codegen/xai-grok-pager/src/headless.rs | 11 +- .../codegen/xai-grok-pager/src/minimal/api.rs | 5 +- crates/codegen/xai-grok-pager/src/models.rs | 4 +- .../src/scrollback/blocks/session_event.rs | 54 +- .../src/scrollback/state/mod.rs | 147 +- .../xai-grok-pager/src/settings/mod.rs | 6 +- .../xai-grok-pager/src/settings/registry.rs | 20 + .../codegen/xai-grok-pager/src/share_cmd.rs | 4 +- .../src/views/dashboard/peek.rs | 6 +- .../src/views/dashboard/render.rs | 4 +- .../src/views/prompt_widget/mod.rs | 75 +- .../src/views/prompt_widget/tests.rs | 50 + .../src/views/settings_modal/render.rs | 160 +- .../src/views/settings_modal/state.rs | 19 +- .../src/views/settings_modal/tests.rs | 197 +- .../xai-grok-pager/src/views/turn_status.rs | 133 +- .../xai-grok-pager/src/worktree_cmd/mod.rs | 7 +- ...eader_mode_remote_dismiss_on_model_pick.rs | 24 +- .../bash_full_output_double_click_fold_pty.rs | 76 +- .../xai-grok-pager/tests/pty_e2e/common.rs | 18 +- .../empty_enter_sends_top_not_last_of_two.rs | 31 +- ...rkers.rs => endline_park_is_markerless.rs} | 95 +- ... => endline_wakeups_close_with_markers.rs} | 55 +- .../minimal_continue_reprints_transcript.rs | 8 + .../queued_message_renders_once_not_twice.rs | 19 +- ...r.rs => reparked_wait_stays_markerless.rs} | 108 +- .../spinner_reappears_after_wait_resumes.rs | 27 +- .../tests/pty_e2e_persistence.rs | 12 +- .../xai-grok-shell-base/src/util/mod.rs | 9 + crates/codegen/xai-grok-shell/CHANGELOG.md | 51 + crates/codegen/xai-grok-shell/Cargo.toml | 6 +- crates/codegen/xai-grok-shell/README.md | 26 + .../xai-grok-shell/benches/fork_copy.rs | 139 + .../xai-grok-shell/changelogs/0.2.112.json | 192 ++ .../xai-grok-shell/changelogs/0.2.112.md | 49 + .../xai-grok-shell/src/agent/activity.rs | 25 +- .../codegen/xai-grok-shell/src/agent/app.rs | 240 +- .../xai-grok-shell/src/agent/config.rs | 134 + .../codegen/xai-grok-shell/src/agent/init.rs | 74 +- .../xai-grok-shell/src/agent/models.rs | 22 +- .../src/agent/mvp_agent/agent_ops.rs | 16 +- .../src/agent/subagent/handle_request.rs | 6 +- .../xai-grok-shell/src/extensions/hooks.rs | 1 + .../codegen/xai-grok-shell/src/inspect/mod.rs | 73 +- .../xai-grok-shell/src/remote/client.rs | 7 +- .../session/acp_session_impl/hooks_plugins.rs | 14 +- .../src/session/acp_session_impl/spawn.rs | 139 +- .../acp_session_tests/client_hooks_tests.rs | 1 + .../xai-grok-shell/src/session/merge.rs | 33 +- .../xai-grok-shell/src/session/telemetry.rs | 27 +- .../xai-grok-shell/src/terminal/adapter.rs | 175 +- .../src/terminal/output_recorder.rs | 58 + .../xai-grok-shell/src/util/config/mcp.rs | 337 ++- .../codegen/xai-grok-shell/src/util/hooks.rs | 34 +- .../codegen/xai-grok-shell/src/util/limits.rs | 85 + crates/codegen/xai-grok-shell/src/util/mod.rs | 1 + .../tests/team_managed_config.rs | 2399 ----------------- .../tests/test_session_end_hook_e2e.rs | 122 + .../xai-grok-tools/src/types/output.rs | 59 +- crates/codegen/xai-grok-version/Cargo.toml | 2 +- .../xai-grok-workspace-client/src/lib.rs | 7 + .../src/rpc/deploy.rs | 128 - .../src/rpc/export_github.rs | 98 + .../xai-grok-workspace-types/src/rpc/hooks.rs | 52 +- .../xai-grok-workspace-types/src/rpc/mod.rs | 1 + .../codegen/xai-grok-workspace/src/error.rs | 46 +- .../xai-grok-workspace/src/export_github.rs | 753 ++++++ .../codegen/xai-grok-workspace/src/handle.rs | 1 + .../xai-grok-workspace/src/hub_server.rs | 55 +- crates/codegen/xai-grok-workspace/src/lib.rs | 1 + .../src/permission/manager.rs | 101 +- .../xai-grok-workspace/src/permission/mod.rs | 1 + .../src/permission/prompter.rs | 23 +- .../src/permission/shell_access.rs | 263 +- .../xai-grok-workspace/src/rpc_envelope.rs | 53 +- .../xai-grok-workspace/src/workspace_ops.rs | 74 +- 138 files changed, 7305 insertions(+), 5818 deletions(-) create mode 100644 crates/codegen/xai-grok-pager-pty-harness/tests/settings_locked_row_e2e.rs rename crates/codegen/xai-grok-pager/tests/pty_e2e/{endline_park_two_static_markers.rs => endline_park_is_markerless.rs} (52%) rename crates/codegen/xai-grok-pager/tests/pty_e2e/{endline_wakeups_are_markerless.rs => endline_wakeups_close_with_markers.rs} (71%) rename crates/codegen/xai-grok-pager/tests/pty_e2e/{reparked_wait_repushes_buried_marker.rs => reparked_wait_stays_markerless.rs} (65%) create mode 100644 crates/codegen/xai-grok-shell/benches/fork_copy.rs create mode 100644 crates/codegen/xai-grok-shell/changelogs/0.2.112.json create mode 100644 crates/codegen/xai-grok-shell/changelogs/0.2.112.md create mode 100644 crates/codegen/xai-grok-shell/src/util/limits.rs delete mode 100644 crates/codegen/xai-grok-shell/tests/team_managed_config.rs create mode 100644 crates/codegen/xai-grok-shell/tests/test_session_end_hook_e2e.rs create mode 100644 crates/codegen/xai-grok-workspace-types/src/rpc/export_github.rs create mode 100644 crates/codegen/xai-grok-workspace/src/export_github.rs diff --git a/Cargo.lock b/Cargo.lock index 7ee0410..4d7f4f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13243,6 +13243,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "toml", "tracing", "url", "xai-grok-config", @@ -13395,7 +13396,7 @@ dependencies = [ [[package]] name = "xai-grok-pager" -version = "0.2.111" +version = "0.2.112" dependencies = [ "agent-client-protocol", "ansi-to-tui", @@ -13485,7 +13486,7 @@ dependencies = [ [[package]] name = "xai-grok-pager-bin" -version = "0.2.111" +version = "0.2.112" dependencies = [ "anyhow", "clap", @@ -13749,7 +13750,7 @@ dependencies = [ [[package]] name = "xai-grok-shell" -version = "0.2.111" +version = "0.2.112" dependencies = [ "agent-client-protocol", "anyhow", @@ -14149,7 +14150,7 @@ dependencies = [ [[package]] name = "xai-grok-version" -version = "0.2.111" +version = "0.2.112" dependencies = [ "semver", ] diff --git a/SOURCE_REV b/SOURCE_REV index e063200..93d1715 100644 --- a/SOURCE_REV +++ b/SOURCE_REV @@ -1 +1 @@ -9b8d35b46d959c042ea9aa31cbbebbd1f0c5c527 +d02693a856a54f1030695b36b91d276e96b30b23 diff --git a/crates/codegen/xai-grok-agent/src/error.rs b/crates/codegen/xai-grok-agent/src/error.rs index 300533e..b71dd5f 100644 --- a/crates/codegen/xai-grok-agent/src/error.rs +++ b/crates/codegen/xai-grok-agent/src/error.rs @@ -21,6 +21,11 @@ pub enum AgentBuildError { #[error("IO error during agent construction: {0}")] IoError(#[from] std::io::Error), + /// Failed to build the session's tokio runtime (fd exhaustion: the + /// runtime needs epoll/kqueue + waker fds). + #[error("failed to build session runtime: {0}")] + RuntimeBuild(std::io::Error), + /// MiniJinja template rendering failed (extend or full mode). /// Includes line numbers and context from the template. #[error("template rendering error: {0}")] diff --git a/crates/codegen/xai-grok-agent/src/plugins/hooks_adapter.rs b/crates/codegen/xai-grok-agent/src/plugins/hooks_adapter.rs index 0edad8d..3cb60f7 100644 --- a/crates/codegen/xai-grok-agent/src/plugins/hooks_adapter.rs +++ b/crates/codegen/xai-grok-agent/src/plugins/hooks_adapter.rs @@ -1,68 +1,15 @@ -//! Plugin hooks adapter — pre-filter and source-entry builder. -//! -//! This module is a bridge between plugin hook JSON files and the shared -//! `xai-grok-hooks` runtime. It pre-filters unsupported events from plugin -//! hook files before passing them to `parse_hook_file()`, and injects -//! plugin-specific environment variables into the resulting `HookSpec` entries. -//! -//! This is NOT a second hooks engine — it feeds into the existing -//! `xai-grok-hooks` crate's parser and runtime. +//! Plugin hooks adapter: pre-filter plugin hook JSON, then feed it to +//! `xai-grok-hooks`' parser and inject plugin env vars. Not a second engine. use std::collections::HashMap; use std::path::{Path, PathBuf}; use xai_grok_hooks::config::{HookSpec, parse_hook_file}; +use xai_grok_hooks::event::HookEventName; use super::manifest::substitute_env_vars; -/// Supported hook event names. -/// Both PascalCase and snake_case forms are accepted. -const SUPPORTED_EVENTS: &[&str] = &[ - // v0 events — PascalCase and snake_case - "SessionStart", - "PreToolUse", - "PostToolUse", - "SessionEnd", - "session_start", - "pre_tool_use", - "post_tool_use", - "session_end", - // v2 events — PascalCase and snake_case - "Notification", - "Stop", - "StopFailure", - "UserPromptSubmit", - "PostToolUseFailure", - "PermissionDenied", - "SubagentStart", - "SubagentStop", - // `SubagentEnd` is the legacy alias for `SubagentStop`. - "SubagentEnd", - "PreCompact", - "PostCompact", - "notification", - "stop", - "stop_failure", - "user_prompt_submit", - "post_tool_use_failure", - "permission_denied", - "subagent_start", - "subagent_stop", - "subagent_end", - "pre_compact", - "post_compact", -]; - -/// Parse plugin hook files with pre-filtering and env injection. -/// -/// For each trusted plugin with hooks, this function: -/// 1. Reads the hooks JSON file -/// 2. Pre-filters unsupported event names (avoiding parse failures) -/// 3. Parses via `parse_hook_file()` -/// 4. Injects plugin-specific env vars into each resulting `HookSpec` -/// -/// Returns `(specs, warnings)` — specs are ready to merge into the -/// `HookRegistry`, warnings are unsupported-handler or parse errors. +/// Read, pre-filter, parse, and env-inject a plugin's hooks file. pub fn parse_plugin_hooks( hooks_path: &Path, plugin_name: &str, @@ -93,11 +40,7 @@ pub fn parse_plugin_hooks( (specs, warnings) } -/// Parse inline hooks from a manifest JSON value. -/// -/// Same pipeline as [`parse_plugin_hooks()`] but skips the file I/O step. -/// The `value` is expected to be the manifest's inline hooks object, -/// structured as `{ "hooks": { "EventName": [...] } }`. +/// Like [`parse_plugin_hooks`] for an inline manifest hooks value (no file I/O). pub fn parse_plugin_hooks_from_value( value: &serde_json::Value, plugin_name: &str, @@ -156,9 +99,7 @@ fn process_hooks_content( warnings.push(msg); } - // Build plugin env vars. `GROK_PLUGIN_*` is the native contract; - // `CLAUDE_PLUGIN_*` aliases the same values for external hooks that read - // those names. + // Native `GROK_PLUGIN_*` vars plus their vendor-compat aliases. let plugin_env: HashMap = HashMap::from([ ("GROK_PLUGIN_ROOT".to_string(), plugin_root.to_string()), ("CLAUDE_PLUGIN_ROOT".to_string(), plugin_root.to_string()), @@ -166,37 +107,23 @@ fn process_hooks_content( ("CLAUDE_PLUGIN_DATA".to_string(), plugin_data.to_string()), ]); - // Inject env vars and update source labels. - // - // The plugin adapter owns the keys in `plugin_env` (CLAUDE_PLUGIN_ROOT - // etc.), so plugin-injected values must always win over any - // user-declared `env` on the hook JSON for those specific keys -- - // otherwise a plugin author could (deliberately or by accident) pin - // the plugin root to an arbitrary path and break the plugin - // contract. User-declared keys not owned by the plugin are - // preserved. for spec in &mut specs { + // Plugin-owned keys always win over user-declared `env`, or a plugin + // author could repoint the plugin root and break the contract. for (k, v) in &plugin_env { spec.extra_env.insert(k.clone(), v.clone()); } - // Prefix name with plugin namespace for identification - spec.name = format!("plugin/{}/{}", plugin_name, spec.name); - // Substitute plugin env vars in command paths at config-load time so - // that hooks like `${CLAUDE_PLUGIN_ROOT}/hooks/foo.sh` resolve to the - // real plugin directory regardless of which spawn branch the runner - // takes (mirrors what managed_mcp does for MCP server commands). + spec.layer = xai_grok_hooks::config::HookProvenance::Plugin; + spec.name = format!( + "{}{}/{}", + xai_grok_hooks::config::PLUGIN_HOOK_PREFIX, + plugin_name, + spec.name + ); + // Resolve plugin path placeholders at load time (mirrors managed_mcp) + // so the command works regardless of the runner's spawn branch. if let Some(cmd) = &spec.command { let cmd_str = cmd.to_string_lossy(); - // Mirror what `managed_mcp::load_plugin_mcp_servers_from_config` - // does for plugin MCP server commands: first substitute the - // plugin-specific placeholders (`${CLAUDE_PLUGIN_ROOT}` and - // friends), then run the result through the generic - // `${VAR}` / `$VAR` env expansion. Doing both passes at - // config-load time keeps hook env var resolution consistent - // with managed MCP server resolution and avoids relying on - // the runtime `sh -c` shell-metachar heuristic in - // `xai-grok-hooks::runner::command` for env vars whose - // values are already known at load time. let substituted = substitute_env_vars(&cmd_str, plugin_root, plugin_data); let expanded = xai_grok_config::expand_env_vars_in_string(&substituted); if expanded != cmd_str { @@ -208,21 +135,16 @@ fn process_hooks_content( (specs, warnings) } -/// Pre-filter unsupported event names from a hooks JSON file. -/// -/// Parses the JSON, removes event keys from the `"hooks"` object that are -/// not in the supported set, and returns the filtered JSON string plus the -/// list of removed event names. -/// -/// This is critical because the hooks crate uses `HashMap` -/// deserialization which causes a full parse failure on unknown event names. +/// Drop `hooks` event keys the parser wouldn't accept, returning the filtered +/// JSON and the removed names. Not needed for correctness (the parser is lenient) +/// but surfaces the drops to the plugin author as warnings. A key is supported +/// exactly when [`HookEventName::parse_key`] accepts it, so there is no allowlist +/// to drift. fn prefilter_unsupported_events(json_content: &str) -> (String, Vec) { let mut value: serde_json::Value = match serde_json::from_str(json_content) { Ok(v) => v, - Err(_) => { - // If JSON is invalid, return as-is and let parse_hook_file handle the error - return (json_content.to_string(), vec![]); - } + // Invalid JSON: let parse_hook_file report it. + Err(_) => return (json_content.to_string(), vec![]), }; let mut skipped = Vec::new(); @@ -230,7 +152,7 @@ fn prefilter_unsupported_events(json_content: &str) -> (String, Vec) { if let Some(hooks_obj) = value.get_mut("hooks").and_then(|v| v.as_object_mut()) { let keys_to_remove: Vec = hooks_obj .keys() - .filter(|key| !SUPPORTED_EVENTS.contains(&key.as_str())) + .filter(|key| HookEventName::parse_key(key).is_none()) .cloned() .collect(); @@ -424,12 +346,8 @@ mod tests { assert!(warnings.iter().any(|w| w.contains("FutureEvent"))); } - /// Regression: hook commands that reference - /// `${CLAUDE_PLUGIN_ROOT}` (or its `GROK_PLUGIN_ROOT` alias) must be - /// substituted at config-load time so the runner spawns the real - /// plugin path. Without substitution the runner's pre-spawn env-var - /// check refuses to run such hooks (the dispatcher fail-opens so the - /// tool call itself is not blocked, but the hook never runs). + /// Regression: plugin path placeholders must resolve at load time, else the + /// runner's pre-spawn env check refuses to run the hook. #[test] fn parse_plugin_hooks_substitutes_plugin_root_in_command() { let value = serde_json::json!({ @@ -471,12 +389,8 @@ mod tests { ); } - // The plugin adapter must NOT mutate - // `command_raw`. The pager UI / ACP DTO surface the raw form - // for display so users see what they wrote (and so any secrets - // resolved from `extra_env` don't leak). A future "tidy" pass - // that mistakenly rewrote `command_raw` would silently break - // the secrets-leakage protection. + // `command_raw` must stay unmodified: it's the display form and rewriting + // it would leak `extra_env`-resolved secrets. let raws: Vec<&str> = specs .iter() .map(|s| s.command_raw.as_deref().unwrap_or("")) @@ -503,22 +417,8 @@ mod tests { assert!(warnings.is_empty()); } - /// Regression: plugin hook commands that reference generic env vars - /// (e.g. `${HOME}` / `$HOME`) must be expanded at config-load time - /// just like managed MCP server commands. Otherwise resolution - /// depends on the runtime `sh -c` heuristic in - /// `xai-grok-hooks::runner::command`, which can fail for hooks - /// whose handler doesn't otherwise contain shell metacharacters. - /// Plugin hooks must not be double-expanded: a `${CLAUDE_PLUGIN_ROOT}` - /// reference resolves to the plugin root exactly once, and the result - /// contains no leftover `$` placeholders. This is the contract the - /// hooks_adapter has long held, and it must continue to hold - /// now that `parse_hook_file` itself does an env-expansion pass with - /// the per-hook `extra_env`. The first pass (in `parse_hook_file`) - /// runs against an EMPTY `extra_env` for plugin hooks (the adapter - /// only fills it in afterwards), so the placeholder survives that - /// pass and the second pass (here, after `extra_env` is wired in) - /// resolves it. + /// Regression: generic env vars (`${HOME}`) resolve at load time, and plugin + /// placeholders resolve exactly once (no leftover `$`, no double-expansion). #[test] fn parse_plugin_hooks_resolves_plugin_root_exactly_once() { let value = serde_json::json!({ @@ -553,17 +453,10 @@ mod tests { ); } - /// Plugin hook JSON may declare its own `env` map. The user-declared - /// keys land in `extra_env`, but the plugin adapter MUST override - /// any user-declared value for keys the plugin owns - /// (CLAUDE_PLUGIN_ROOT, GROK_PLUGIN_ROOT, CLAUDE_PLUGIN_DATA, - /// GROK_PLUGIN_DATA). This preserves the plugin contract while still - /// supporting user-defined env vars on plugin hooks. + /// User-declared `env` is kept, but the four plugin-owned keys always win. #[test] fn parse_plugin_hooks_user_env_merged_with_plugin_precedence() { - // Exercise ALL FOUR plugin-owned keys, not just - // CLAUDE_PLUGIN_ROOT. A regression that only iterates one key - // would otherwise pass. + // All four keys, so a one-key regression can't pass. let value = serde_json::json!({ "hooks": { "PreToolUse": [ @@ -601,10 +494,7 @@ mod tests { "user-declared env keys must survive plugin merge" ); - // All four plugin-owned keys: plugin wins, user's attempt is - // overridden. CLAUDE_PLUGIN_ROOT and GROK_PLUGIN_ROOT both map - // to plugin_root; CLAUDE_PLUGIN_DATA and GROK_PLUGIN_DATA both - // map to plugin_data. + // All four plugin-owned keys: plugin wins over the user's attempt. for (key, expected) in [ ("CLAUDE_PLUGIN_ROOT", "/actual/plugin/root"), ("GROK_PLUGIN_ROOT", "/actual/plugin/root"), @@ -621,10 +511,7 @@ mod tests { #[test] fn parse_plugin_hooks_expands_generic_env_vars_in_command() { - // SAFETY: only mutated within this single-threaded test. - // SAFETY: this test sets process env vars; tokio test macros - // serialize tests within the same module by default but to be - // robust use a uniquely-named var. + // Uniquely-named var so concurrent tests don't collide. let var = "GB1183_HOOKS_ADAPTER_TEST_HOME"; // SAFETY: env writes are not thread-safe; this test is single-threaded. unsafe { diff --git a/crates/codegen/xai-grok-config-types/src/lib.rs b/crates/codegen/xai-grok-config-types/src/lib.rs index b753db6..a7fda0a 100644 --- a/crates/codegen/xai-grok-config-types/src/lib.rs +++ b/crates/codegen/xai-grok-config-types/src/lib.rs @@ -711,6 +711,9 @@ pub struct RemoteSettings { /// Tighten-only, like `external_otel_disabled`. #[serde(default)] pub external_otel_content_gates_locked: Option, + /// `Some(false)` disarms managed-config signature verification (remote kill-switch). + #[serde(default)] + pub managed_config_signature_verification: Option, #[serde(default)] pub telemetry_enabled: Option, /// Telemetry mode override (string): `"session-metrics"`, `"full"`, `"off"`. diff --git a/crates/codegen/xai-grok-config-types/src/mcp.rs b/crates/codegen/xai-grok-config-types/src/mcp.rs index 2a34e86..fc975da 100644 --- a/crates/codegen/xai-grok-config-types/src/mcp.rs +++ b/crates/codegen/xai-grok-config-types/src/mcp.rs @@ -45,7 +45,9 @@ pub enum McpServerTransportConfig { cwd: Option, }, StreamableHttp { - #[serde(default, alias = "urlTemplate", alias = "url_template")] + // Not `default`: a missing url must fail to deserialize, not become a + // fake HTTP server with an empty url. + #[serde(alias = "urlTemplate", alias = "url_template")] url: String, #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")] transport_type: Option, @@ -66,7 +68,53 @@ pub enum McpServerTransportConfig { }, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum McpServerProblemSeverity { + Error, + Warning, +} + +/// A problem found loading an `[mcp_servers.*]` entry. Reported (never fatal) +/// and surfaced through `grok inspect`. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct McpServerConfigProblem { + pub server: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub field: Option, + pub severity: McpServerProblemSeverity, + pub message: String, +} + +/// Recognized wire keys for an `[mcp_servers.*]` entry. Needed because the +/// flattened untagged transport enum bypasses `serde_ignored`. Kept in sync by +/// `known_mcp_server_fields_cover_serialized_keys`. +pub const KNOWN_MCP_SERVER_FIELDS: &[&str] = &[ + "args", + "bearer_token_env_var", + "command", + "cwd", + "enabled", + "env", + "expose_image_base64", + "headers", + "oauth", + "oauth_client_id", + "oauth_client_secret_env_var", + "oauth_scopes", + "setup", + "startup_timeout_sec", + "tool_timeout_sec", + "tool_timeouts", + "type", + "url", + // Deserialize-only aliases for `url`. + "urlTemplate", + "url_template", +]; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct McpJsonOAuthBlock { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -188,6 +236,22 @@ pub struct McpServerConfig { pub expose_image_base64: Option, } +impl McpServerConfig { + /// The transport field (`command` or `url`) that is present but blank, if + /// any. Such a server can never connect, so the loader drops it. + pub fn blank_transport_field(&self) -> Option<&'static str> { + match &self.transport { + McpServerTransportConfig::Stdio { command, .. } if command.trim().is_empty() => { + Some("command") + } + McpServerTransportConfig::StreamableHttp { url, .. } if url.trim().is_empty() => { + Some("url") + } + _ => None, + } + } +} + fn render_setup_template( input: &str, variables: &HashMap, @@ -518,6 +582,106 @@ mod tests { }"# } + #[test] + fn transport_less_entry_fails_to_deserialize() { + for value in [ + serde_json::json!({ "enabled": false }), + serde_json::json!({ "enabled": true }), + serde_json::json!({}), + ] { + assert!( + serde_json::from_value::(value.clone()).is_err(), + "transport-less entry must not deserialize: {value}" + ); + } + } + + #[test] + fn blank_transport_field_is_detected_symmetrically() { + let blank_url: McpServerConfig = + serde_json::from_value(serde_json::json!({ "url": " " })).unwrap(); + assert_eq!(blank_url.blank_transport_field(), Some("url")); + + let blank_command: McpServerConfig = + serde_json::from_value(serde_json::json!({ "command": "\t" })).unwrap(); + assert_eq!(blank_command.blank_transport_field(), Some("command")); + + let ok: McpServerConfig = + serde_json::from_value(serde_json::json!({ "command": "npx" })).unwrap(); + assert_eq!(ok.blank_transport_field(), None); + } + + /// A newly added field cannot silently escape `KNOWN_MCP_SERVER_FIELDS`. + #[test] + fn known_mcp_server_fields_cover_serialized_keys() { + let stdio = McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "npx".into(), + args: vec!["-y".into()], + env: Some(HashMap::from([("A".into(), "b".into())])), + cwd: Some("/tmp".into()), + }, + enabled: true, + oauth: Some(McpJsonOAuthBlock::default()), + setup: None, + startup_timeout_sec: Some(10), + tool_timeout_sec: Some(20), + tool_timeouts: Some(HashMap::from([("t".into(), 1)])), + expose_image_base64: Some(true), + }; + let http = McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://x/mcp".into(), + transport_type: Some("http".into()), + bearer_token_env_var: Some("TOK".into()), + headers: Some(HashMap::from([("H".into(), "v".into())])), + oauth_client_id: Some("id".into()), + oauth_client_secret_env_var: Some("SEC".into()), + oauth_scopes: Some(vec!["s".into()]), + }, + enabled: true, + oauth: None, + setup: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + tool_timeouts: None, + expose_image_base64: None, + }; + for config in [stdio, http] { + let value = serde_json::to_value(&config).unwrap(); + for key in value.as_object().unwrap().keys() { + assert!( + KNOWN_MCP_SERVER_FIELDS.contains(&key.as_str()), + "field `{key}` is serialized but missing from KNOWN_MCP_SERVER_FIELDS" + ); + } + } + } + + #[test] + fn stdio_and_http_still_parse() { + let stdio: McpServerConfig = serde_json::from_value(serde_json::json!({ + "command": "npx", + "args": ["-y", "pkg"] + })) + .unwrap(); + assert!(stdio.enabled); + assert!(matches!( + stdio.transport, + McpServerTransportConfig::Stdio { .. } + )); + + let http: McpServerConfig = serde_json::from_value(serde_json::json!({ + "url": "https://mcp.example.com/mcp" + })) + .unwrap(); + assert!(matches!( + http.transport, + McpServerTransportConfig::StreamableHttp { .. } + )); + assert!(http.to_acp_mcp_server("x").is_some()); + } + #[test] fn mcp_setup_schema_parses_and_missing_preference_requires_setup() { let config: McpConfig = serde_json::from_str(site_select_setup_json()).unwrap(); diff --git a/crates/codegen/xai-grok-config/src/lib.rs b/crates/codegen/xai-grok-config/src/lib.rs index d12b452..9e77c7d 100644 --- a/crates/codegen/xai-grok-config/src/lib.rs +++ b/crates/codegen/xai-grok-config/src/lib.rs @@ -44,12 +44,14 @@ pub use global_hook_sources::{ validate_direct_hook_json_file, validated_hook_json_files_for_sources, }; pub use loader::{ - CampaignsState, ConfigLayers, MANAGED_CONFIG_FILENAME, ManagedConfigLayer, - REQUIREMENTS_FILENAME, apply_version_overrides_with_registered, campaigns_application_disabled, - campaigns_state_path, deep_merge_toml, expand_env_vars_in_string, expand_env_vars_in_toml, - load_config_file, load_dismissed_ids_from_home, load_effective_config_disk_only, - load_from_disk, load_managed_config, load_system_managed_config, load_toml_file, - managed_config_layers, managed_config_layers_at, toml_error_detail, + CampaignsState, ConfigLayers, HookConfigLayer, HookProvenance, MANAGED_CONFIG_FILENAME, + ManagedConfigLayer, REQUIREMENTS_FILENAME, USER_CONFIG_FILENAME, + apply_version_overrides_with_registered, campaigns_application_disabled, campaigns_state_path, + deep_merge_toml, expand_env_vars_in_string, expand_env_vars_in_toml, hook_config_layers, + hook_config_layers_at, load_config_file, load_dismissed_ids_from_home, + load_effective_config_disk_only, load_from_disk, load_managed_config, + load_system_managed_config, load_toml_file, managed_config_layers, managed_config_layers_at, + toml_error_detail, }; pub use macos_managed::MDM_REQUIREMENTS_SOURCE; pub use managed_cache::{ diff --git a/crates/codegen/xai-grok-config/src/loader.rs b/crates/codegen/xai-grok-config/src/loader.rs index 595f597..bd34090 100644 --- a/crates/codegen/xai-grok-config/src/loader.rs +++ b/crates/codegen/xai-grok-config/src/loader.rs @@ -9,14 +9,12 @@ use crate::paths::{system_config_dir, user_grok_home}; use crate::validation::{load_requirements, load_system_requirements}; use crate::version_overrides::{self, apply_version_overrides}; -/// Load and parse a TOML file, expanding `$VAR` references. Empty table if absent. -pub fn load_toml_file(path: &Path) -> std::io::Result { +/// Read and parse a TOML file WITHOUT `$VAR` expansion (empty table if absent). +/// Shared core of [`load_toml_file`] and the hook-layer read. +fn read_toml_file(path: &Path) -> std::io::Result { match std::fs::read_to_string(path) { Ok(s) => match toml::from_str::(&s) { - Ok(mut v) => { - expand_env_vars_in_toml(&mut v); - Ok(v) - } + Ok(v) => Ok(v), Err(e) => { // Built from the span, never from Display — Display echoes the // offending source line, which may carry a secret. Safe to log and @@ -36,6 +34,13 @@ pub fn load_toml_file(path: &Path) -> std::io::Result { } } +/// Load and parse a TOML file, expanding `$VAR` references. Empty table if absent. +pub fn load_toml_file(path: &Path) -> std::io::Result { + let mut v = read_toml_file(path)?; + expand_env_vars_in_toml(&mut v); + Ok(v) +} + /// A snippet-free description of a TOML parse error: `"TOML parse error at line /// L, column C: "` (or just the message when there's no span). Never /// includes the offending source line — `Display` echoes it and it may carry a @@ -81,9 +86,12 @@ pub fn load_config_file(path: &Path) -> std::io::Result { } pub fn load_from_disk() -> std::io::Result { - load_user_config_layer(user_grok_home().as_deref(), "config.toml") + load_user_config_layer(user_grok_home().as_deref(), USER_CONFIG_FILENAME) } +/// User config filename (`$GROK_HOME/config.toml`), shared by the loaders here. +pub const USER_CONFIG_FILENAME: &str = "config.toml"; + /// Managed config filename, shared by the loaders in this module. pub const MANAGED_CONFIG_FILENAME: &str = "managed_config.toml"; @@ -158,6 +166,228 @@ pub fn managed_config_layers_at( layers } +/// A hook's origin (held by `xai_grok_hooks::HookSpec::layer`). Defined here, not +/// in `xai-grok-hooks`, since the dep direction is `xai-grok-hooks -> xai-grok-config`; +/// this crate sets the config tiers, `File`/`Plugin` are set downstream. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HookProvenance { + /// `/etc/grok/managed_config.toml`. + SystemManaged, + /// `$GROK_HOME/managed_config.toml` (server-synced). + Managed, + /// `requirements.toml` (user or system tier). + Requirements, + /// `$GROK_HOME/config.toml`. + User, + /// A JSON hook file (the hooks directory, a vendor settings file, or a + /// configured hooks path). + File, + /// A plugin-contributed hook. + Plugin, + /// A tier this build doesn't recognize (e.g. a newer peer's provenance over + /// the wire). Forward-tolerant so an unknown value degrades to a + /// conservative origin instead of failing the whole `HookRegistry` decode. + #[serde(other)] + Unknown, +} + +/// Defaults to `File` so pre-provenance wire records decode as the most +/// conservative origin. +impl Default for HookProvenance { + fn default() -> Self { + Self::File + } +} + +impl HookProvenance { + /// The snake_case wire string (matches the derived serde representation). + pub fn as_str(self) -> &'static str { + match self { + Self::SystemManaged => "system_managed", + Self::Managed => "managed", + Self::Requirements => "requirements", + Self::User => "user", + Self::File => "file", + Self::Plugin => "plugin", + Self::Unknown => "unknown", + } + } +} + +impl std::str::FromStr for HookProvenance { + type Err = std::convert::Infallible; + + /// Inverse of [`HookProvenance::as_str`]. Unrecognized strings map to + /// [`HookProvenance::Unknown`] (forward-tolerant), so this never fails. + fn from_str(s: &str) -> Result { + Ok(match s { + "system_managed" => Self::SystemManaged, + "managed" => Self::Managed, + "requirements" => Self::Requirements, + "user" => Self::User, + "file" => Self::File, + "plugin" => Self::Plugin, + _ => Self::Unknown, + }) + } +} + +/// One config layer's `hooks` subtree (read without `$VAR` expansion) plus its +/// provenance. +#[derive(Debug, Clone)] +pub struct HookConfigLayer { + provenance: HookProvenance, + source_name: String, + path: std::path::PathBuf, + hooks: toml::Value, +} + +impl HookConfigLayer { + /// Construct a layer directly (in-memory config and tests); the synthesized + /// `path` mirrors `source_name`. The normal path is [`hook_config_layers`]. + pub fn new( + provenance: HookProvenance, + source_name: impl Into, + hooks: toml::Value, + ) -> Self { + let source_name = source_name.into(); + let path = std::path::PathBuf::from(&source_name); + Self { + provenance, + source_name, + path, + hooks, + } + } + + pub fn provenance(&self) -> HookProvenance { + self.provenance + } + + /// A stable label for this layer (e.g. `"managed"`, `"requirements/user"`), + /// used to prefix hook names for display and dedup. + pub fn source_name(&self) -> &str { + &self.source_name + } + + /// The layer's backing file, so parse errors can cite a real path. + pub fn path(&self) -> &std::path::Path { + &self.path + } + + /// The raw `hooks` table, unexpanded so a literal `${VAR}` reaches the runner. + pub fn hooks(&self) -> &toml::Value { + &self.hooks + } +} + +/// All config-layer `hooks` blocks, highest authority first (matching +/// [`effective_config_base`]). Read WITHOUT env-expansion and never merged (hooks +/// combine additively downstream); absent/unparsable layers are skipped with a +/// warning so one bad layer can't drop the others. macOS MDM is excluded (not a +/// TOML file; MDM hooks belong to the enforcement work). +pub fn hook_config_layers() -> Vec { + hook_config_layers_at(system_config_dir().as_deref(), user_grok_home().as_deref()) +} + +/// [`hook_config_layers`] with explicit directories, for tests. +pub fn hook_config_layers_at( + system_dir: Option<&Path>, + user_home: Option<&Path>, +) -> Vec { + /// One candidate config-hook layer: which directory + filename to read, and + /// the provenance/label to stamp on hooks found there. + struct LayerSpec<'a> { + dir: Option<&'a Path>, + filename: &'a str, + provenance: HookProvenance, + source_name: &'a str, + } + + // Highest config authority first, matching `effective_config_base` precedence + // (requirements > user > managed > system_managed; user overrides managed in + // this model). Order only affects which label a byte-identical duplicate keeps + // under first-wins dedup; every distinct hook runs regardless. + let specs = [ + LayerSpec { + dir: system_dir, + filename: REQUIREMENTS_FILENAME, + provenance: HookProvenance::Requirements, + source_name: "requirements/system", + }, + LayerSpec { + dir: user_home, + filename: REQUIREMENTS_FILENAME, + provenance: HookProvenance::Requirements, + source_name: "requirements/user", + }, + LayerSpec { + dir: user_home, + filename: USER_CONFIG_FILENAME, + provenance: HookProvenance::User, + source_name: "user", + }, + LayerSpec { + dir: user_home, + filename: MANAGED_CONFIG_FILENAME, + provenance: HookProvenance::Managed, + source_name: "managed", + }, + LayerSpec { + dir: system_dir, + filename: MANAGED_CONFIG_FILENAME, + provenance: HookProvenance::SystemManaged, + source_name: "system_managed", + }, + ]; + + let mut layers = Vec::new(); + for LayerSpec { + dir, + filename, + provenance, + source_name, + } in specs + { + let Some(path) = dir.map(|d| d.join(filename)) else { + continue; + }; + if !path.is_file() { + continue; + } + // No `$VAR` expansion: a literal `${VAR}` must reach the hook runner, which + // does the single expansion (expanding here would double-expand). + let mut value = match read_toml_file(&path) { + Ok(v) => v, + Err(e) => { + tracing::warn!(path = %path.display(), error = %e, "skipping config layer whose hooks could not be read"); + continue; + } + }; + // Apply `[[version_overrides]]` (parity with `load_config_file`); deep-merge + // only, no `$VAR` expansion, so the raw-read invariant holds. + if let Err(e) = apply_version_overrides_with_registered(&mut value) { + tracing::warn!(path = %path.display(), error = %e, "skipping config layer whose version_overrides failed to apply"); + continue; + } + let Some(hooks) = value.get("hooks") else { + continue; + }; + if !hooks.is_table() { + tracing::warn!(path = %path.display(), "ignoring non-table `hooks` value in config layer"); + continue; + } + layers.push(HookConfigLayer { + provenance, + source_name: source_name.to_string(), + path: path.clone(), + hooks: hooks.clone(), + }); + } + layers +} + /// Layers lowest→highest priority. `[[campaigns]]` taken off each layer at load. #[derive(Clone)] pub struct ConfigLayers { @@ -465,6 +695,58 @@ pub fn expand_env_vars_in_string(input: &str) -> String { mod tests { use super::*; + fn write(dir: &Path, name: &str, contents: &str) { + std::fs::write(dir.join(name), contents).unwrap(); + } + + #[test] + fn hook_config_layers_reads_each_layer_unmerged_with_provenance() { + let sys = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + write( + home.path(), + "config.toml", + "[[hooks.PreToolUse]]\nmatcher = \"Bash\"\n[[hooks.PreToolUse.hooks]]\ntype = \"command\"\ncommand = \"${HOME}/u.sh\"\n", + ); + write( + home.path(), + MANAGED_CONFIG_FILENAME, + "[[hooks.PreToolUse]]\n[[hooks.PreToolUse.hooks]]\ntype = \"command\"\ncommand = \"/m.sh\"\n", + ); + write( + sys.path(), + REQUIREMENTS_FILENAME, + "[[hooks.PostToolUse]]\n[[hooks.PostToolUse.hooks]]\ntype = \"command\"\ncommand = \"/r.sh\"\n", + ); + + let layers = hook_config_layers_at(Some(sys.path()), Some(home.path())); + + // Highest authority first, each layer keeping its own provenance. + let names: Vec<_> = layers.iter().map(|l| l.source_name().to_string()).collect(); + assert_eq!(names, vec!["requirements/system", "user", "managed"]); + assert_eq!(layers[1].provenance(), HookProvenance::User); + // Unmerged, and `${HOME}` stays literal (the runner expands, not the loader). + let cmd = layers[1].hooks()["PreToolUse"][0]["hooks"][0]["command"] + .as_str() + .unwrap(); + assert_eq!(cmd, "${HOME}/u.sh"); + } + + #[test] + fn hook_config_layers_bad_user_layer_does_not_drop_managed() { + // A broken user config.toml must not drop the admin managed layer. + let home = tempfile::tempdir().unwrap(); + write(home.path(), "config.toml", "this is = = not valid toml"); + write( + home.path(), + MANAGED_CONFIG_FILENAME, + "[[hooks.PreToolUse]]\n[[hooks.PreToolUse.hooks]]\ntype = \"command\"\ncommand = \"/m.sh\"\n", + ); + let layers = hook_config_layers_at(None, Some(home.path())); + let names: Vec<_> = layers.iter().map(|l| l.source_name().to_string()).collect(); + assert_eq!(names, vec!["managed"]); + } + #[test] fn full_layer_precedence_requirements_over_config_over_managed() { let system_managed: toml::Value = diff --git a/crates/codegen/xai-grok-config/src/managed_cache.rs b/crates/codegen/xai-grok-config/src/managed_cache.rs index 650f0cf..f83fe91 100644 --- a/crates/codegen/xai-grok-config/src/managed_cache.rs +++ b/crates/codegen/xai-grok-config/src/managed_cache.rs @@ -382,7 +382,7 @@ fn effective_now(cache: Option<&ManagedConfigCache>) -> u64 { /// A signing-enabled build over a legacy unsigned / edited / forged or foreign-bound /// cache refetches a signed copy; likewise when an imposing claim has no policy /// sidecar satisfying it — the states the gate refuses on, so refusal always comes -/// with a pending self-heal. Dark build or no policy on disk → false. +/// with a pending self-heal. Keyless build or no policy on disk → false. fn signed_cache_needs_refetch( home: &Path, cache: Option<&ManagedConfigCache>, diff --git a/crates/codegen/xai-grok-config/src/managed_cache/claim_tests.rs b/crates/codegen/xai-grok-config/src/managed_cache/claim_tests.rs index 36d65ac..6ab1fc2 100644 --- a/crates/codegen/xai-grok-config/src/managed_cache/claim_tests.rs +++ b/crates/codegen/xai-grok-config/src/managed_cache/claim_tests.rs @@ -68,10 +68,10 @@ fn claim_not_consulted_on_sidecar_read_blip() { ); } -/// Dark build: a claim file on disk changes neither the gate nor staleness. +/// Armed: garbage claim alone (no fail-closed) does not trip gate or force refetch. #[test] -fn claim_paths_are_inert_in_dark_build() { - assert!(!crate::signed_policy::verification_active()); +fn garbage_claim_without_fail_closed_is_not_imposing() { + assert!(crate::signed_policy::verification_active()); let dir = tempfile::tempdir().unwrap(); let home = dir.path(); mark_managed_config_synced_at( @@ -91,10 +91,43 @@ fn claim_paths_are_inert_in_dark_build() { .unwrap(); assert!( !managed_policy_compromised_for_at(home, &team("team-a")), - "dark build: a claim file must not make the gate fail closed" + "garbage claim without fail-closed must not make the gate fail closed" ); assert!( !is_managed_config_hard_stale_for_at(home, &team("team-a")), - "dark build: a claim file must not force a refetch" + "garbage claim without fail-closed must not force a refetch" ); } + +/// Keyless: claim file does not affect gate or staleness. +#[test] +fn claim_paths_are_inert_in_dark_build() { + crate::signed_policy::test_seam::with_dark(|| { + assert!(!crate::signed_policy::verification_active()); + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + mark_managed_config_synced_at( + home, + SyncMarker { + principal: Some("team-a"), + had_managed_config: false, + had_requirements: false, + key_fingerprint: None, + fail_closed: false, + }, + ); + std::fs::write( + home.join(crate::signed_policy::MANAGED_IDENTITY_SIDECAR_FILE), + r#"{"signed_payload":"{}","signature":"","key_id":""}"#, + ) + .unwrap(); + assert!( + !managed_policy_compromised_for_at(home, &team("team-a")), + "dark build: a claim file must not make the gate fail closed" + ); + assert!( + !is_managed_config_hard_stale_for_at(home, &team("team-a")), + "dark build: a claim file must not force a refetch" + ); + }); +} diff --git a/crates/codegen/xai-grok-config/src/managed_cache/tests.rs b/crates/codegen/xai-grok-config/src/managed_cache/tests.rs index 7b43495..d9cb69b 100644 --- a/crates/codegen/xai-grok-config/src/managed_cache/tests.rs +++ b/crates/codegen/xai-grok-config/src/managed_cache/tests.rs @@ -356,7 +356,7 @@ fn managed_config_stale_at_is_true_for_old_sync() { let _ = std::fs::remove_dir_all(&dir); } -/// A served-then-deleted artifact reads stale regardless of the timer. +/// Served-then-deleted is stale; armed also treats unsigned-on-disk as stale. #[test] fn managed_config_stale_when_served_artifact_deleted() { let dir = std::env::temp_dir().join(format!("grok-stale-artgone-{}", std::process::id())); @@ -372,8 +372,13 @@ fn managed_config_stale_when_served_artifact_deleted() { }, ); std::fs::write(dir.join("requirements.toml"), "[features]\n").unwrap(); - assert!(!managed_config_stale_at(Some(&dir), &team("team-1"))); + let cache = read_managed_config_cache(&dir).unwrap(); + // present → usable; deleted → tamper + assert!(!cache_unusable_for(&cache, &dir, &team("team-1"))); + // armed: unsigned policy still hard-stale + assert!(managed_config_stale_at(Some(&dir), &team("team-1"))); std::fs::remove_file(dir.join("requirements.toml")).unwrap(); + assert!(cache_unusable_for(&cache, &dir, &team("team-1"))); assert!(managed_config_stale_at(Some(&dir), &team("team-1"))); let _ = std::fs::remove_dir_all(&dir); } @@ -441,7 +446,7 @@ fn managed_config_legacy_marker_is_conservative() { let _ = std::fs::remove_dir_all(&dir); } -/// Hard-staleness: missing artifact or identity mismatch → true; a fresh same-identity cache → false. +/// Hard-stale: missing artifact or identity mismatch; fresh same-identity is usable. #[test] fn hard_stale_only_on_missing_or_identity() { let dir = std::env::temp_dir().join(format!("grok-hardstale-{}", std::process::id())); @@ -457,12 +462,16 @@ fn hard_stale_only_on_missing_or_identity() { }, ); std::fs::write(dir.join("requirements.toml"), "[features]\n").unwrap(); - // Same identity, artifact present → not hard-stale (even past the timer). - assert!(!is_managed_config_hard_stale_for_at(&dir, &team("team-a"))); - // Different identity → hard-stale. - assert!(is_managed_config_hard_stale_for_at(&dir, &team("team-b"))); - // Served artifact deleted → hard-stale. + let cache = read_managed_config_cache(&dir).unwrap(); + // same identity + present → usable + assert!(!cache_unusable_for(&cache, &dir, &team("team-a"))); + // different identity → unusable + assert!(cache_unusable_for(&cache, &dir, &team("team-b"))); + // deleted artifact → unusable std::fs::remove_file(dir.join("requirements.toml")).unwrap(); + assert!(cache_unusable_for(&cache, &dir, &team("team-a"))); + // armed: unsigned still hard-stale + std::fs::write(dir.join("requirements.toml"), "[features]\n").unwrap(); assert!(is_managed_config_hard_stale_for_at(&dir, &team("team-a"))); let _ = std::fs::remove_dir_all(&dir); } @@ -494,7 +503,7 @@ fn corrupt_marker_reads_as_no_marker_and_allows() { let _ = std::fs::remove_dir_all(&dir); } -/// A deploy-key switch is detected offline as an identity mismatch (`cache_unusable_for`) and refetched online. +/// Deploy-key switch → offline identity mismatch (refetch online). #[test] fn deployment_key_switch_is_stale_and_tampered_offline() { let dir = std::env::temp_dir().join(format!("grok-dk-switch-{}", std::process::id())); @@ -511,18 +520,21 @@ fn deployment_key_switch_is_stale_and_tampered_offline() { }, ); std::fs::write(dir.join("requirements.toml"), "[features]\n").unwrap(); + let cache = read_managed_config_cache(&dir).unwrap(); - // Same key, artifacts intact → not stale, not tampered. - assert!(!is_managed_config_hard_stale_for_at(&dir, &dkey("fp-a"))); - assert!(!managed_config_stale_at(Some(&dir), &dkey("fp-a"))); + // same key → usable + assert!(!cache_unusable_for(&cache, &dir, &dkey("fp-a"))); + assert!(!cache_key_fingerprint_mismatch(&cache, &dkey("fp-a"))); - // Different key (fp-b) → identity mismatch: hard-stale and tampered. + // different key → unusable + assert!(cache_unusable_for(&cache, &dir, &dkey("fp-b"))); + assert!(cache_key_fingerprint_mismatch(&cache, &dkey("fp-b"))); assert!(is_managed_config_hard_stale_for_at(&dir, &dkey("fp-b"))); assert!(managed_config_stale_at(Some(&dir), &dkey("fp-b"))); let _ = std::fs::remove_dir_all(&dir); } -/// A pre-upgrade marker (no `key_fingerprint`) must not fire when a key is now configured — it self-upgrades next sync. +/// Pre-upgrade marker (no fingerprint) must not fire key dimension. #[test] fn pre_upgrade_marker_without_fingerprint_does_not_fire_on_key() { let dir = std::env::temp_dir().join(format!("grok-dk-preupgrade-{}", std::process::id())); @@ -538,16 +550,14 @@ fn pre_upgrade_marker_without_fingerprint_does_not_fire_on_key() { ) .unwrap(); std::fs::write(dir.join("requirements.toml"), "[features]\n").unwrap(); - // A key is configured now but the marker has none → no key mismatch, no spurious refetch. - assert!(!is_managed_config_hard_stale_for_at( - &dir, - &dkey("fp-current") - )); - assert!(!managed_config_stale_at(Some(&dir), &dkey("fp-current"))); + // key now configured, marker has none → no mismatch + let cache = read_managed_config_cache(&dir).unwrap(); + assert!(!cache_key_fingerprint_mismatch(&cache, &dkey("fp-current"))); + assert!(!cache_unusable_for(&cache, &dir, &dkey("fp-current"))); let _ = std::fs::remove_dir_all(&dir); } -/// The team path keys on `principal` (team id), records no fingerprint, and never fires a key mismatch. +/// Team path: principal only; never a key mismatch. #[test] fn team_path_keys_on_principal_not_key_fingerprint() { let dir = std::env::temp_dir().join(format!("grok-team-nofp-{}", std::process::id())); @@ -563,9 +573,12 @@ fn team_path_keys_on_principal_not_key_fingerprint() { }, ); std::fs::write(dir.join("requirements.toml"), "[features]\n").unwrap(); + let cache = read_managed_config_cache(&dir).unwrap(); // Team path: identity carries no fingerprint → never a key mismatch. - assert!(!is_managed_config_hard_stale_for_at(&dir, &team("team-a"))); + assert!(!cache_key_fingerprint_mismatch(&cache, &team("team-a"))); + assert!(!cache_unusable_for(&cache, &dir, &team("team-a"))); // A team switch is still detected via principal (unchanged behavior). + assert!(cache_unusable_for(&cache, &dir, &team("team-b"))); assert!(is_managed_config_hard_stale_for_at(&dir, &team("team-b"))); // No key fingerprint is recorded on the team path. let marker = std::fs::read_to_string(dir.join(MANAGED_CONFIG_CACHE_FILE)).unwrap(); @@ -736,7 +749,7 @@ fn blank_principal_is_never_a_confirmed_switch() { let _ = std::fs::remove_dir_all(&dir); } -/// Compromised only when opted in AND tampered; opted-out / never-synced / config-less / intact is never flagged. +/// Armed: fail-closed + served policy requires authentic sidecar. #[test] fn compromised_only_when_opted_in_and_deleted_or_substituted() { let dir = std::env::temp_dir().join(format!("grok-compromised-{}", std::process::id())); @@ -746,7 +759,7 @@ fn compromised_only_when_opted_in_and_deleted_or_substituted() { let _ = std::fs::remove_file(dir.join(MANAGED_CONFIG_CACHE_FILE)); assert!(!managed_policy_compromised_for_at(&dir, &team("team-a"))); - // Opted-in + present + same identity → not compromised. + // opted-in, no sidecar → refuse when armed std::fs::write(dir.join("requirements.toml"), "[features]\n").unwrap(); mark_managed_config_synced_at( &dir, @@ -758,7 +771,7 @@ fn compromised_only_when_opted_in_and_deleted_or_substituted() { fail_closed: true, }, ); - assert!(!managed_policy_compromised_for_at(&dir, &team("team-a"))); + assert!(managed_policy_compromised_for_at(&dir, &team("team-a"))); // Served-then-deleted (admin opted in) → compromised. std::fs::remove_file(dir.join("requirements.toml")).unwrap(); @@ -798,15 +811,16 @@ fn compromised_only_when_opted_in_and_deleted_or_substituted() { let _ = std::fs::remove_dir_all(&dir); } -/// The OTHER served artifact — `managed_config.toml`, not just `requirements.toml` — -/// deleted under a fail_closed marker is compromised. +/// fail_closed + deleted managed_config.toml is compromised. #[test] fn compromised_on_managed_config_deletion_when_fail_closed() { + use crate::signed_policy::SignedVerdict; let dir = std::env::temp_dir().join(format!("grok-compromised-mc-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("managed_config.toml"), "[cli]\n").unwrap(); + let home = dir.as_path(); + std::fs::create_dir_all(home).unwrap(); + std::fs::write(home.join("managed_config.toml"), "[cli]\n").unwrap(); mark_managed_config_synced_at( - &dir, + home, SyncMarker { principal: Some("team-a"), had_managed_config: true, @@ -815,24 +829,43 @@ fn compromised_on_managed_config_deletion_when_fail_closed() { fail_closed: true, }, ); - // Present → not compromised. - assert!(!managed_policy_compromised_for_at(&dir, &team("team-a"))); + let cache = read_managed_config_cache(home); + // present → not compromised (marker) + assert!(!managed_policy_compromised_decision( + SignedVerdict::Inactive, + || false, + false, + cache.as_ref(), + home, + &team("team-a") + )); // Served-then-deleted managed_config.toml → compromised by the missing artifact. - std::fs::remove_file(dir.join("managed_config.toml")).unwrap(); - assert!(managed_policy_compromised_for_at(&dir, &team("team-a"))); + std::fs::remove_file(home.join("managed_config.toml")).unwrap(); + assert!(managed_policy_compromised_decision( + SignedVerdict::Inactive, + || false, + false, + cache.as_ref(), + home, + &team("team-a") + )); + // armed public gate refuses sidecar-less fail-closed + assert!(managed_policy_compromised_for_at(home, &team("team-a"))); let _ = std::fs::remove_dir_all(&dir); } -/// Deployment-key path: an opted-in marker is compromised on an offline key switch (the fingerprint is the only offline identity). +/// Offline deploy-key switch on opted-in marker is compromised. #[test] fn compromised_on_deployment_key_switch_when_fail_closed() { + use crate::signed_policy::SignedVerdict; let dir = std::env::temp_dir().join(format!("grok-compromised-dk-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let home = dir.as_path(); + std::fs::create_dir_all(home).unwrap(); // Provisioned with key A (fp-a), opted into fail_closed, artifact present. - std::fs::write(dir.join("requirements.toml"), "[features]\n").unwrap(); + std::fs::write(home.join("requirements.toml"), "[features]\n").unwrap(); mark_managed_config_synced_at( - &dir, + home, SyncMarker { principal: Some("dep-A"), had_managed_config: false, @@ -841,16 +874,32 @@ fn compromised_on_deployment_key_switch_when_fail_closed() { fail_closed: true, }, ); + let cache = read_managed_config_cache(home); - // Same key, offline → not compromised (ALLOW). - assert!(!managed_policy_compromised_for_at(&dir, &dkey("fp-a"))); + // same key offline → allow (marker) + assert!(!managed_policy_compromised_decision( + SignedVerdict::Inactive, + || false, + false, + cache.as_ref(), + home, + &dkey("fp-a") + )); + // different key offline → refuse + assert!(managed_policy_compromised_decision( + SignedVerdict::Inactive, + || false, + true, + cache.as_ref(), + home, + &dkey("fp-b") + )); + // armed public gate agrees + assert!(managed_policy_compromised_for_at(home, &dkey("fp-b"))); - // Different key (fp-b) → compromised (REFUSE): offline deploy-key switch. - assert!(managed_policy_compromised_for_at(&dir, &dkey("fp-b"))); - - // Not opted in (fail_closed=false): a key switch is NOT failed closed. + // fail_closed=false: key switch not refused mark_managed_config_synced_at( - &dir, + home, SyncMarker { principal: Some("dep-A"), had_managed_config: false, @@ -859,22 +908,23 @@ fn compromised_on_deployment_key_switch_when_fail_closed() { fail_closed: false, }, ); - assert!(!managed_policy_compromised_for_at(&dir, &dkey("fp-b"))); + assert!(!managed_policy_compromised_for_at(home, &dkey("fp-b"))); let _ = std::fs::remove_dir_all(&dir); } -/// The gate refuses ONLY on tamper for the CURRENT principal (artifact missing or changed key fingerprint), -/// never a pure identity mismatch; staleness still treats that mismatch as a refetch trigger (asserted alongside). +/// Marker refuses only current-principal tamper, not pure identity mismatch. #[test] fn gate_excludes_pure_identity_mismatch_but_keeps_artifact_and_key_tamper() { + use crate::signed_policy::SignedVerdict; let dir = std::env::temp_dir().join(format!("grok-gate-fix1-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let home = dir.as_path(); + std::fs::create_dir_all(home).unwrap(); // (1) Principal A (fail_closed), artifact intact; serving team-b = pure identity mismatch → ALLOWED. - std::fs::write(dir.join("requirements.toml"), "[features]\n").unwrap(); + std::fs::write(home.join("requirements.toml"), "[features]\n").unwrap(); mark_managed_config_synced_at( - &dir, + home, SyncMarker { principal: Some("dep-A"), had_managed_config: false, @@ -883,19 +933,27 @@ fn gate_excludes_pure_identity_mismatch_but_keeps_artifact_and_key_tamper() { fail_closed: true, }, ); + let cache = read_managed_config_cache(home); assert!( - !managed_policy_compromised_for_at(&dir, &team("team-b")), - "a foreign/stale principal's fail_closed must NOT refuse the current session" + !managed_policy_compromised_decision( + SignedVerdict::Inactive, + || false, + false, + cache.as_ref(), + home, + &team("team-b") + ), + "a foreign/stale principal's fail_closed must NOT refuse on the marker path" ); // ...but still stale for B → the refetch path rebinds online. assert!( - is_managed_config_hard_stale_for_at(&dir, &team("team-b")), + is_managed_config_hard_stale_for_at(home, &team("team-b")), "a pure identity mismatch must still trigger a refetch (rebind)" ); - // (2) Same principal, served artifact now missing → still REFUSED offline. + // (2) same principal, artifact missing → refuse offline mark_managed_config_synced_at( - &dir, + home, SyncMarker { principal: Some("team-b"), had_managed_config: false, @@ -904,16 +962,25 @@ fn gate_excludes_pure_identity_mismatch_but_keeps_artifact_and_key_tamper() { fail_closed: true, }, ); - std::fs::remove_file(dir.join("requirements.toml")).unwrap(); + std::fs::remove_file(home.join("requirements.toml")).unwrap(); + let cache = read_managed_config_cache(home); assert!( - managed_policy_compromised_for_at(&dir, &team("team-b")), + managed_policy_compromised_decision( + SignedVerdict::Inactive, + || false, + false, + cache.as_ref(), + home, + &team("team-b") + ), "same-principal served-then-deleted artifact must fail closed offline" ); + assert!(managed_policy_compromised_for_at(home, &team("team-b"))); // (3) Deploy-key fingerprint mismatch for the current key → still REFUSED. - std::fs::write(dir.join("requirements.toml"), "[features]\n").unwrap(); + std::fs::write(home.join("requirements.toml"), "[features]\n").unwrap(); mark_managed_config_synced_at( - &dir, + home, SyncMarker { principal: Some("dep-A"), had_managed_config: false, @@ -922,24 +989,35 @@ fn gate_excludes_pure_identity_mismatch_but_keeps_artifact_and_key_tamper() { fail_closed: true, }, ); + let cache = read_managed_config_cache(home); assert!( - managed_policy_compromised_for_at(&dir, &dkey("fp-b")), + managed_policy_compromised_decision( + SignedVerdict::Inactive, + || false, + true, + cache.as_ref(), + home, + &dkey("fp-b") + ), "a changed deployment-key fingerprint must fail closed offline" ); + assert!(managed_policy_compromised_for_at(home, &dkey("fp-b"))); let _ = std::fs::remove_dir_all(&dir); } -/// Opt-in comes from the served response, not disk, so a no-write sync can't disarm the gate. +/// Opt-in from response, not disk; no-write sync cannot disarm. #[test] fn mark_keeps_fail_closed_armed_without_on_disk_file() { + use crate::signed_policy::SignedVerdict; let dir = std::env::temp_dir().join(format!("grok-mark-disarm-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let home = dir.as_path(); + std::fs::create_dir_all(home).unwrap(); - // Opted-in policy served + present → not compromised (intact). - std::fs::write(dir.join("requirements.toml"), "[features]\n").unwrap(); + // opted-in + present → allow (marker) + std::fs::write(home.join("requirements.toml"), "[features]\n").unwrap(); mark_managed_config_synced_at( - &dir, + home, SyncMarker { principal: Some("team-1"), had_managed_config: false, @@ -948,15 +1026,31 @@ fn mark_keeps_fail_closed_armed_without_on_disk_file() { fail_closed: true, }, ); - assert!(!managed_policy_compromised_for_at(&dir, &team("team-1"))); + let cache = read_managed_config_cache(home); + assert!(!managed_policy_compromised_decision( + SignedVerdict::Inactive, + || false, + false, + cache.as_ref(), + home, + &team("team-1") + )); - // Tamper: delete the served file → compromised. - std::fs::remove_file(dir.join("requirements.toml")).unwrap(); - assert!(managed_policy_compromised_for_at(&dir, &team("team-1"))); + // delete served file → compromised + std::fs::remove_file(home.join("requirements.toml")).unwrap(); + assert!(managed_policy_compromised_decision( + SignedVerdict::Inactive, + || false, + false, + cache.as_ref(), + home, + &team("team-1") + )); + assert!(managed_policy_compromised_for_at(home, &team("team-1"))); // A no-write sync (file still absent) stays armed: opt-in is from the response. mark_managed_config_synced_at( - &dir, + home, SyncMarker { principal: Some("team-1"), had_managed_config: false, @@ -966,13 +1060,13 @@ fn mark_keeps_fail_closed_armed_without_on_disk_file() { }, ); assert!( - managed_policy_compromised_for_at(&dir, &team("team-1")), + managed_policy_compromised_for_at(home, &team("team-1")), "a no-write sync must not disarm the fail-closed gate" ); - // An opt-OUT (fail_closed=false) still takes effect. + // fail_closed=false still takes effect mark_managed_config_synced_at( - &dir, + home, SyncMarker { principal: Some("team-1"), had_managed_config: false, @@ -981,7 +1075,7 @@ fn mark_keeps_fail_closed_armed_without_on_disk_file() { fail_closed: false, }, ); - assert!(!managed_policy_compromised_for_at(&dir, &team("team-1"))); + assert!(!managed_policy_compromised_for_at(home, &team("team-1"))); let _ = std::fs::remove_dir_all(&dir); } @@ -1247,9 +1341,37 @@ fn fetch_resets_an_inflated_rollback_floor() { ); } -/// Dark build: public tick is a no-op over an existing marker. +/// Keyless: public tick is a no-op. #[test] fn bump_rollback_floor_is_inert_when_dark() { + crate::signed_policy::test_seam::with_dark(|| { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + mark_managed_config_synced_at( + home, + SyncMarker { + principal: Some("team-a"), + had_managed_config: false, + had_requirements: false, + key_fingerprint: None, + fail_closed: false, + }, + ); + let floor = |home: &Path| read_managed_config_cache(home).map_or(0, |c| c.rollback_floor); + let base = floor(home); + assert!(!crate::signed_policy::verification_active()); + bump_rollback_floor_with_now(home, base + 10_000); + assert_eq!( + floor(home), + base, + "dark build: the tick must not move the floor" + ); + }); +} + +/// Armed: public tick raises the floor. +#[test] +fn bump_rollback_floor_raises_when_verification_active() { let dir = tempfile::tempdir().unwrap(); let home = dir.path(); mark_managed_config_synced_at( @@ -1264,12 +1386,13 @@ fn bump_rollback_floor_is_inert_when_dark() { ); let floor = |home: &Path| read_managed_config_cache(home).map_or(0, |c| c.rollback_floor); let base = floor(home); - assert!(!crate::signed_policy::verification_active()); - bump_rollback_floor_with_now(home, base + 10_000); + assert!(crate::signed_policy::verification_active()); + let raised = base + 10_000; + bump_rollback_floor_with_now(home, raised); assert_eq!( floor(home), - base, - "dark build: the tick must not move the floor" + raised, + "armed build: the tick must raise the floor" ); } diff --git a/crates/codegen/xai-grok-config/src/signed_policy.rs b/crates/codegen/xai-grok-config/src/signed_policy.rs index d682dfb..1484449 100644 --- a/crates/codegen/xai-grok-config/src/signed_policy.rs +++ b/crates/codegen/xai-grok-config/src/signed_policy.rs @@ -1,23 +1,39 @@ //! Ed25519-signed, identity-bound managed-policy envelope. //! -//! The server signs a canonical payload (the served policy, the bound principal, -//! an expiry) with an Ed25519 private key; the client verifies it against a -//! compiled-in trusted key set (selected by the signed `key_id`, so keys can -//! rotate), binds it to the active principal, and checks the on-disk policy -//! matches the signed bytes — so an in-place edit is caught, not just a deletion. -//! Inert until a public key is provisioned: with no embedded keys the cache -//! marker stays the (best-effort) authority. +//! Server signs policy + principal + expiry; client verifies against a compiled-in +//! key set (by signed `key_id`), binds principal, and checks on-disk bytes match. +//! This build is armed (prod `v1` key); keyless (`&[]`) keeps the cache marker as authority. + +use std::sync::atomic::{AtomicBool, Ordering}; + use base64::Engine; + +// Shared wire types with the deployment-config server: a field rename breaks compile on both sides. pub use prod_mc_cli_chat_proxy_types::{ MANAGED_CONFIG_NONCE_ECHO_HEADER, MANAGED_IDENTITY_TYP, MANAGED_POLICY_TYP, ManagedIdentityClaim, SignatureEnvelope, SignedPayload, is_server_nonce_shape, now_unix, }; -/// Compiled-in trusted Ed25519 public keys, `(key_id, raw 32 bytes)`; more than one -/// entry only during a rotation. Empty ships dark (see [`verification_active`]). -/// Compile-time, not an env flag: the local attacker controls their env. -/// Provisioning order: keyed clients reject `typ`-less envelopes, so the -/// typ-emitting server must be fully rolled out before any client embeds a key. -pub const EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS: &[(&str, &[u8])] = &[]; + +/// Compiled-in trusted keys `(key_id, raw 32 bytes)`. Prod `v1`. Empty = dark (no verification). +/// The private signing key never lives in this crate or in client env flags. +/// +/// - base64: `BxP2cxaRIzlhxUvqmlz9e/dIBeWX58P4whEW0sFrdzI=` +/// - SHA-256: `fb4dcc77c757465b953265146d495166527fcc1c2b365352f8d20c3d8f6de620` +/// +/// Ship only after the server is emitting valid envelopes for this key id. +pub const EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS: &[(&str, &[u8])] = &[( + "v1", + &[ + 7, 19, 246, 115, 22, 145, 35, 57, 97, 197, 75, 234, 154, 92, 253, 123, 247, 72, 5, 229, + 151, 231, 195, 248, 194, 17, 22, 210, 193, 107, 119, 50, + ], +)]; + +/// SHA-256 of raw `v1` pubkey (hex); test pin against silent typos. +pub const EMBEDDED_V1_PUBKEY_SHA256_HEX: &str = + "fb4dcc77c757465b953265146d495166527fcc1c2b365352f8d20c3d8f6de620"; + +// Compile-time sanity for the key set. const _: () = { let keys = EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS; let mut i = 0; @@ -41,6 +57,7 @@ const _: () = { i += 1; } }; + const fn const_str_eq(a: &str, b: &str) -> bool { let (a, b) = (a.as_bytes(), b.as_bytes()); if a.len() != b.len() { @@ -55,16 +72,95 @@ const fn const_str_eq(a: &str, b: &str) -> bool { } true } -/// Run `f` over the trusted key set — the compiled-in [`EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS`], -/// unless the compile-time-excluded test seam overrides it. -fn with_embedded_keys(f: impl FnOnce(&[(&str, &[u8])]) -> R) -> R { - f(EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS) + +/// Debug-only key override for tests (`test` or `test-signing-seam`); never release. +#[cfg(all(test, debug_assertions))] +pub mod test_seam { + use std::cell::RefCell; + use std::sync::RwLock; + + /// Owned key list: `(key_id, raw 32-byte pubkey)`. + type OwnedKeys = Vec<(String, Vec)>; + /// `None` = compiled-in keys; `Some([])` = dark; `Some(non-empty)` = override. + type KeyOverride = Option; + + // Process override. + pub(super) static GLOBAL_OVERRIDE: RwLock = RwLock::new(None); + + // Thread-local override (unit tests; avoids racing armed global). + // Outer `Option`: unset vs set on this thread. Inner is [`KeyOverride`]. + thread_local! { + static LOCAL_OVERRIDE: RefCell> = const { RefCell::new(None) }; + } + + fn to_owned_keys(keys: Option<&[(&str, &[u8])]>) -> KeyOverride { + keys.map(|ks| { + ks.iter() + .map(|(id, key)| ((*id).to_owned(), key.to_vec())) + .collect() + }) + } + + /// Process keys: `None` clear, `Some(&[])` dark, else these keys. + pub fn set_embedded_keys(keys: Option<&[(&str, &[u8])]>) { + *GLOBAL_OVERRIDE.write().unwrap_or_else(|e| e.into_inner()) = to_owned_keys(keys); + } + + /// Dark keys on this thread for `f` only. + pub fn with_dark(f: impl FnOnce() -> R) -> R { + LOCAL_OVERRIDE.with(|cell| { + let prev = cell.replace(Some(Some(Vec::new()))); + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + let prev = self.0.take(); + LOCAL_OVERRIDE.with(|cell| { + *cell.borrow_mut() = prev; + }); + } + } + let _restore = Restore(prev); + f() + }) + } + + pub(super) fn with_override(f: impl FnOnce(Option<&[(String, Vec)]>) -> R) -> R { + if let Some(local) = LOCAL_OVERRIDE.with(|c| c.borrow().clone()) { + f(local.as_deref()) + } else { + let global = GLOBAL_OVERRIDE.read().unwrap_or_else(|e| e.into_inner()); + f(global.as_deref()) + } + } } + +fn with_embedded_keys(f: impl FnOnce(&[(&str, &[u8])]) -> R) -> R { + #[cfg(all(test, debug_assertions))] + { + test_seam::with_override(|overridden| match overridden { + Some(keys) => { + let view: Vec<(&str, &[u8])> = keys + .iter() + .map(|(id, key)| (id.as_str(), key.as_slice())) + .collect(); + f(&view) + } + None => f(EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS), + }) + } + #[cfg(not(all(test, debug_assertions)))] + { + f(EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS) + } +} + /// Sidecar persisted next to the policy so the load-time gate can re-verify it offline. pub const SIGNATURE_SIDECAR_FILE: &str = "managed_config.sig.json"; + /// The is-managed claim's own sidecar (see /// [`prod_mc_cli_chat_proxy_types::ManagedIdentityClaim`]). pub const MANAGED_IDENTITY_SIDECAR_FILE: &str = "managed_identity.sig.json"; + #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum SigError { #[error("signature is not valid base64")] @@ -88,17 +184,56 @@ pub enum SigError { #[error("on-disk {0} cannot be read")] Unreadable(&'static str), } -/// Whether the client must require + verify a signature — true iff the key set is -/// non-empty (no env toggle; see [`EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS`]). + +/// Remote kill-switch; set only from authenticated remote settings. +static REMOTE_VERIFICATION_DISARMED: AtomicBool = AtomicBool::new(false); + +/// True when keys are embedded and the remote kill-switch has not disarmed. pub fn verification_active() -> bool { + if REMOTE_VERIFICATION_DISARMED.load(Ordering::Relaxed) { + return false; + } with_embedded_keys(|keys| !keys.is_empty()) } + +/// Apply remote `managed_config_signature_verification`. +/// +/// - `Some(false)` disarms only when `settings_origin_trusted` is true **or** no +/// keys are embedded (dark: disarm is a no-op for enforcement). An untrusted +/// origin (env-overridden proxy) cannot disarm a keyed client — that would make +/// the kill-switch an env toggle. +/// - `None` / `Some(true)` re-arm always (stronger / default). +/// +/// Call only when settings were successfully fetched. Logs on state change. +pub fn apply_remote_managed_config_signature_verification( + setting: Option, + settings_origin_trusted: bool, +) { + let want_disarm = setting == Some(false); + let keys_embedded = with_embedded_keys(|keys| !keys.is_empty()); + if want_disarm && keys_embedded && !settings_origin_trusted { + tracing::warn!( + "ignoring managed_config_signature_verification=false from untrusted settings origin" + ); + return; + } + let disarm = want_disarm; + let prev = REMOTE_VERIFICATION_DISARMED.swap(disarm, Ordering::Relaxed); + if prev != disarm { + tracing::warn!( + disarmed = disarm, + "managed-config signature verification kill-switch changed" + ); + } +} + /// Whether `key_id` names a trusted key. Only PICKS among served envelopes; /// verification re-selects the key from the signed bytes, so a lying hint can at /// most cause a verification failure. pub fn embedded_key_id_trusted(key_id: &str) -> bool { with_embedded_keys(|keys| keys.iter().any(|(id, _)| *id == key_id)) } + /// Verify `signature_b64` over `signed_payload` against `trusted_keys`, returning the /// parsed payload. The verifying key is selected by the SIGNED payload's `key_id` — /// safe to read pre-verification because selection can only land within the trusted @@ -118,6 +253,7 @@ pub fn verify_signed_payload( } Ok(payload) } + /// [`verify_signed_payload`]'s mirror for claims (requires [`MANAGED_IDENTITY_TYP`]). pub fn verify_managed_identity_claim( signed_payload: &str, @@ -132,6 +268,7 @@ pub fn verify_managed_identity_claim( } Ok(claim) } + /// Shared Ed25519 check: select the trusted key named by the signed bytes' `key_id`, verify. fn verify_signature_with_keys( signed_payload: &str, @@ -150,6 +287,7 @@ fn verify_signature_with_keys( .verify(signed_payload.as_bytes(), &sig) .map_err(|_| SigError::SignatureMismatch) } + /// Fetch-time identity binding for a VERIFIED payload, expiry enforced: a /// deployment-signed payload is trusted on signature alone; a team-signed payload /// must match the active team. Lenient on a missing active team — an `auth.json` @@ -173,6 +311,7 @@ pub fn check_fetch_identity( } Ok(()) } + /// Whether the payload's effective principal (`deployment_id`, else `team_id`) matches /// ours — the at-rest identity rule, so another tenant's cache reads foreign. Lenient /// when either side is unknown. Deliberately expiry-free: the gate orders identity @@ -187,6 +326,7 @@ fn signed_principal_matches(payload: &SignedPayload, expected_principal: Option< (Some(signed), Some(expected)) if signed != expected ) } + /// Full verification of a fetched envelope against the embedded trusted keys /// (signature, binding, expiry), returning the trusted payload to persist. pub fn verify_fetched( @@ -196,6 +336,7 @@ pub fn verify_fetched( ) -> Result { with_embedded_keys(|keys| verify_fetched_with_keys(sidecar, keys, active_team_id, now_unix)) } + /// Fetch-time claim verification (signature + expiry; binding is the caller's rule). pub fn verify_fetched_claim( sidecar: &SignatureEnvelope, @@ -203,6 +344,7 @@ pub fn verify_fetched_claim( ) -> Result { with_embedded_keys(|keys| verify_fetched_claim_with_keys(sidecar, keys, now_unix)) } + /// Key-injected core of [`verify_fetched_claim`] so tests can supply throwaway keys. fn verify_fetched_claim_with_keys( sidecar: &SignatureEnvelope, @@ -216,6 +358,7 @@ fn verify_fetched_claim_with_keys( } Ok(claim) } + /// Key-injected core of [`verify_fetched`] so tests can supply throwaway keypairs. fn verify_fetched_with_keys( sidecar: &SignatureEnvelope, @@ -227,6 +370,7 @@ fn verify_fetched_with_keys( check_fetch_identity(&payload, active_team_id, now_unix)?; Ok(payload) } + /// True when something occupies `path` that is not a regular file — directory, /// symlink, fifo, … NO-FOLLOW, so even a symlink to a byte-identical file counts: /// a squatter blocks or redirects reads/rewrites, which is tamper, never a blip. @@ -234,6 +378,7 @@ fn verify_fetched_with_keys( fn non_regular_file_at(path: &std::path::Path) -> bool { std::fs::symlink_metadata(path).is_ok_and(|m| !m.is_file()) } + /// Confirm the on-disk artifacts match the signed payload byte-for-byte — an in-place /// edit is caught, not just a deletion. A signed-ABSENT slot must be empty on disk: a /// locally planted `requirements.toml` (the highest-precedence layer) is tamper, not @@ -275,9 +420,11 @@ pub fn check_on_disk_matches( } Ok(()) } + pub(crate) fn sidecar_path(home: &std::path::Path) -> std::path::PathBuf { home.join(SIGNATURE_SIDECAR_FILE) } + /// Outcome of reading the on-disk sidecar; mirrors the artifact-slot semantics of /// [`check_on_disk_matches`]. enum SidecarRead { @@ -289,9 +436,11 @@ enum SidecarRead { /// gate must not refuse on it, but the refetch trigger fires to self-heal. Unreadable, } + fn read_sidecar(home: &std::path::Path) -> SidecarRead { read_envelope_at(&sidecar_path(home)) } + fn read_envelope_at(path: &std::path::Path) -> SidecarRead { if non_regular_file_at(path) { return SidecarRead::Absent; @@ -306,15 +455,18 @@ fn read_envelope_at(path: &std::path::Path) -> SidecarRead { Err(_) => SidecarRead::Absent, } } + /// Persist the sidecar atomically — a torn sidecar would fail the load-time gate. /// Written 0600 on unix: for a deployment-key principal the signed payload embeds /// the key, so the sidecar is a second at-rest copy of a bearer credential. pub fn write_sidecar(home: &std::path::Path, sidecar: &SignatureEnvelope) -> std::io::Result<()> { write_envelope_at(&sidecar_path(home), sidecar) } + pub(crate) fn managed_identity_sidecar_path(home: &std::path::Path) -> std::path::PathBuf { home.join(MANAGED_IDENTITY_SIDECAR_FILE) } + /// [`write_sidecar`] for the claim (0600 for uniformity; the claim has no secret). pub fn write_managed_identity_sidecar( home: &std::path::Path, @@ -322,11 +474,13 @@ pub fn write_managed_identity_sidecar( ) -> std::io::Result<()> { write_envelope_at(&managed_identity_sidecar_path(home), sidecar) } + fn write_envelope_at(path: &std::path::Path, sidecar: &SignatureEnvelope) -> std::io::Result<()> { let json = serde_json::to_string(sidecar) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; crate::fs_atomic::write_atomically(path, &json, Some(0o600)) } + /// Persisted envelope nonce for [`MANAGED_CONFIG_NONCE_ECHO_HEADER`] (unverified; /// telemetry only, never a trust input). Both guards fail open by skipping the /// echo: only the server mint shape (header-safe, so a corrupt sidecar can't brick @@ -341,6 +495,7 @@ pub fn stored_envelope_nonce( return None; }; let payload: SignedPayload = serde_json::from_str(&sidecar.signed_payload).ok()?; + // Effective principal mirrors the server's bookkeeping: deployment over team. let issued_to = payload .deployment_id .as_deref() @@ -348,6 +503,7 @@ pub fn stored_envelope_nonce( (issued_to == Some(fetch_principal) && is_server_nonce_shape(&payload.nonce)) .then_some(payload.nonce) } + /// Whether an authentic claim IMPOSES fail-closed enforcement: verified, bound to /// the KNOWN `expected_principal`, in-date vs the caller-clamped `now_unix`, and /// `fail_closed`. Anything else imposes nothing: permissive (must not override a @@ -365,6 +521,7 @@ pub fn managed_identity_claim_imposes( managed_identity_claim_imposes_with_keys(home, keys, expected_principal, now_unix) }) } + /// Key-injected core of [`managed_identity_claim_imposes`] so tests can supply throwaway keys. fn managed_identity_claim_imposes_with_keys( home: &std::path::Path, @@ -386,9 +543,10 @@ fn managed_identity_claim_imposes_with_keys( }; claim.principal == expected && now_unix <= claim.expires_at && claim.fail_closed } + /// True when signature verification is active AND a cloud-cache policy on disk is /// NOT covered by a valid, in-date, identity-bound, content-matching signature. -/// Dark build or no policy on disk → false (behavior unchanged until a key ships). +/// Keyless build or no policy on disk → false. pub fn cloud_cache_signature_invalid( home: &std::path::Path, expected_principal: Option<&str>, @@ -401,6 +559,7 @@ pub fn cloud_cache_signature_invalid( cloud_cache_signature_invalid_with_keys(home, keys, expected_principal, now_unix) }) } + /// Key-injected core of [`cloud_cache_signature_invalid`] so tests can supply throwaway keys. fn cloud_cache_signature_invalid_with_keys( home: &std::path::Path, @@ -415,10 +574,13 @@ fn cloud_cache_signature_invalid_with_keys( } use SignedCacheEvaluation as Eval; match evaluate_signed_cache(home, trusted_keys, expected_principal, now_unix) { + // ANY deviation refetches — including read blips (self-heal what the gate stays + // lenient on) and a foreign-but-authentic cache (which would otherwise never rebind). Eval::NoAuthenticSidecar | Eval::SidecarUnreadable => true, Eval::Facts(f) => !f.identity_ok || f.expired || f.disk != DiskStatus::Match, } } + /// On-disk status of the signed artifact slots, from [`check_on_disk_matches`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DiskStatus { @@ -430,6 +592,7 @@ enum DiskStatus { /// the gate. Unreadable, } + /// What one verification pass over the on-disk sidecar establishes. The two public /// checks are projections over the same facts: the refetch trigger flags ANY /// deviation; the gate applies the fail-closed rules. @@ -441,6 +604,7 @@ struct SignedCacheFacts { fail_closed: bool, disk: DiskStatus, } + /// One evaluation of the on-disk sidecar; both public checks project from this. enum SignedCacheEvaluation { /// No authentic sidecar: missing, corrupt, a squatting non-file, forged, or @@ -451,6 +615,7 @@ enum SignedCacheEvaluation { SidecarUnreadable, Facts(SignedCacheFacts), } + /// Read the sidecar, verify it against `trusted_keys`, reduce to a [`SignedCacheEvaluation`]. fn evaluate_signed_cache( home: &std::path::Path, @@ -479,6 +644,7 @@ fn evaluate_signed_cache( }, }) } + /// Verdict of the signed-sidecar check for the load-time gate. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SignedVerdict { @@ -508,6 +674,7 @@ pub enum SignedVerdict { /// disk, expired, or bound to a different principal. Refuse — always. Compromised, } + /// The signed verdict for the on-disk cache; see [`SignedVerdict`]. The fail-closed /// opt-in is read from the SIGNED bytes, not the forgeable marker. `expected_principal` /// is the machine's managed principal (active team id, or the recorded deployment id); @@ -524,6 +691,7 @@ pub fn signed_cache_compromised( signed_cache_compromised_with_keys(home, keys, expected_principal, now_unix) }) } + /// Key-injected core of [`signed_cache_compromised`] so tests can supply throwaway keys. fn signed_cache_compromised_with_keys( home: &std::path::Path, @@ -535,12 +703,18 @@ fn signed_cache_compromised_with_keys( match evaluate_signed_cache(home, trusted_keys, expected_principal, now_unix) { Eval::NoAuthenticSidecar => SignedVerdict::NoAuthenticSidecar, Eval::SidecarUnreadable => SignedVerdict::SidecarUnreadable, + // Identity precedes the fail_closed short-circuit: a foreign-bound but + // permissive policy can't be replayed to escape a strict one offline. Eval::Facts(f) if !f.identity_ok => SignedVerdict::Compromised, Eval::Facts(f) if !f.fail_closed => SignedVerdict::Trusted, + // Opted-in and bound to us: expired or tampered-on-disk refuses; an + // Unreadable blip does not. Eval::Facts(f) if f.expired || f.disk == DiskStatus::Mismatch => SignedVerdict::Compromised, Eval::Facts(_) => SignedVerdict::Trusted, } } + +// Tests in a sibling file (they dwarf the module) but a child module, for private access. #[cfg(test)] #[path = "signed_policy/tests.rs"] mod tests; diff --git a/crates/codegen/xai-grok-config/src/signed_policy/tests.rs b/crates/codegen/xai-grok-config/src/signed_policy/tests.rs index d3a893c..30a4f74 100644 --- a/crates/codegen/xai-grok-config/src/signed_policy/tests.rs +++ b/crates/codegen/xai-grok-config/src/signed_policy/tests.rs @@ -490,23 +490,76 @@ fn sidecar_round_trips_on_disk() { } #[test] -fn verification_inert_without_embedded_key() { - // The feature ships dark: no compiled-in key, no verification. - assert!(EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS.is_empty()); - assert!(!verification_active()); +fn verification_armed_with_embedded_key() { + // Armed: prod v1 key compiled in. + assert!(verification_active()); + assert_eq!(EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS.len(), 1); + assert_eq!(EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS[0].0, "v1"); + assert!(embedded_key_id_trusted("v1")); + assert!(!embedded_key_id_trusted("v0")); + // Fingerprint pin against silent typos. + let digest = ring::digest::digest( + &ring::digest::SHA256, + EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS[0].1, + ); + let hex: String = digest.as_ref().iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!( + hex, EMBEDDED_V1_PUBKEY_SHA256_HEX, + "embedded v1 pubkey bytes must match the documented SHA-256 fingerprint" + ); } -/// Dark build: the public gate is false even with a policy on disk and no sidecar. +/// Empty seam → verification off (incident-disarm shape). #[test] -fn cloud_cache_signature_invalid_is_false_when_dark() { +fn with_dark_forces_keyless_verification_inactive() { + test_seam::with_dark(|| { + assert!( + !verification_active(), + "Some(&[]) must force the keyless build for rollback tests" + ); + assert!(!embedded_key_id_trusted("v1")); + }); + // restored + assert!(verification_active()); +} + +/// Armed: flags missing/untrusted sidecar; nothing on disk → not invalid. +#[test] +fn cloud_cache_signature_invalid_when_armed() { let dir = tempfile::tempdir().unwrap(); - write_policy(dir.path(), &payload()); - assert!(!verification_active()); + assert!(verification_active()); assert!(!cloud_cache_signature_invalid( dir.path(), Some("team-007"), 1_000 )); + write_policy(dir.path(), &payload()); + assert!(cloud_cache_signature_invalid( + dir.path(), + Some("team-007"), + 1_000 + )); + let (kp, _) = test_keypair(); + write_sidecar(dir.path(), &sign(&kp, &payload())).unwrap(); + assert!(cloud_cache_signature_invalid( + dir.path(), + Some("team-007"), + 1_000 + )); +} + +/// Keyless: public gate inert with unsigned policy on disk. +#[test] +fn cloud_cache_signature_invalid_inert_when_dark() { + test_seam::with_dark(|| { + let dir = tempfile::tempdir().unwrap(); + write_policy(dir.path(), &payload()); + assert!(!verification_active()); + assert!( + !cloud_cache_signature_invalid(dir.path(), Some("team-007"), 1_000), + "dark build must not flag unsigned on-disk policy" + ); + }); } /// No policy on disk → nothing to verify → not invalid. @@ -813,10 +866,24 @@ fn signed_cache_compromised_rejects_foreign_permissive_policy() { ); } -/// Dark build: the public entry reads Inactive even with an authentic, opted-in, -/// tampered cache on disk — the marker path then decides. +/// Keyless: public entry → Inactive. #[test] -fn signed_cache_compromised_is_inactive_when_dark() { +fn signed_cache_compromised_inactive_when_dark() { + test_seam::with_dark(|| { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + write_policy(home, &payload()); + assert!(!verification_active()); + assert_eq!( + signed_cache_compromised(home, Some("team-007"), 1_000), + SignedVerdict::Inactive + ); + }); +} + +/// Armed: foreign key → NoAuthenticSidecar (never Inactive). +#[test] +fn signed_cache_compromised_is_no_authentic_sidecar_when_armed() { let dir = tempfile::tempdir().unwrap(); let home = dir.path(); let (kp, _) = test_keypair(); @@ -826,15 +893,10 @@ fn signed_cache_compromised_is_inactive_when_dark() { }; write_policy(home, &p); write_sidecar(home, &sign(&kp, &p)).unwrap(); - std::fs::write( - home.join("requirements.toml"), - "[features]\nweb_fetch = true\n", - ) - .unwrap(); - assert!(!verification_active()); + assert!(verification_active()); assert_eq!( signed_cache_compromised(home, Some("team-007"), 1_000), - SignedVerdict::Inactive + SignedVerdict::NoAuthenticSidecar ); } @@ -967,3 +1029,61 @@ fn rotation_selects_the_trusted_key_by_signed_key_id() { // 1k-line mark); same private access via the #[path] include below. #[path = "claim_tests.rs"] mod claim_tests; + +/// Serialize tests that mutate the process-global kill-switch / key seam. +fn with_remote_disarm_lock(f: impl FnOnce() -> R) -> R { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + f() +} + +#[test] +fn remote_kill_switch_dark_embed_stays_inactive() { + with_remote_disarm_lock(|| { + // Forced dark: inactive regardless of kill-switch (prod embed is keyed). + test_seam::with_dark(|| { + apply_remote_managed_config_signature_verification(Some(true), true); + assert!(!verification_active()); + + apply_remote_managed_config_signature_verification(Some(false), true); + assert!(!verification_active()); + + apply_remote_managed_config_signature_verification(None, true); + assert!(!verification_active()); + }); + }); +} + +/// With keys embedded (prod pin), disarm flips verification off and re-arm restores it. +/// Untrusted origin cannot disarm. +#[test] +fn remote_kill_switch_with_keys_disarms_and_rearms() { + with_remote_disarm_lock(|| { + apply_remote_managed_config_signature_verification(Some(true), true); + assert!( + verification_active(), + "keys embedded + armed must be verification_active" + ); + + apply_remote_managed_config_signature_verification(Some(false), true); + assert!( + !verification_active(), + "trusted Some(false) must disarm keyed verification" + ); + + apply_remote_managed_config_signature_verification(Some(true), true); + assert!( + verification_active(), + "Some(true) must re-arm keyed verification" + ); + + apply_remote_managed_config_signature_verification(Some(false), false); + assert!( + verification_active(), + "untrusted Some(false) must not disarm when keys are embedded" + ); + + apply_remote_managed_config_signature_verification(None, true); + assert!(verification_active()); + }); +} diff --git a/crates/codegen/xai-grok-hooks/Cargo.toml b/crates/codegen/xai-grok-hooks/Cargo.toml index 8af8628..7b62666 100644 --- a/crates/codegen/xai-grok-hooks/Cargo.toml +++ b/crates/codegen/xai-grok-hooks/Cargo.toml @@ -14,6 +14,7 @@ serde_json = { workspace = true } shellexpand = "3.1" thiserror = { workspace = true } tokio = { workspace = true, features = ["process", "rt", "macros", "time"] } +toml = { workspace = true } tracing = { workspace = true } url = { workspace = true } xai-grok-config = { workspace = true } diff --git a/crates/codegen/xai-grok-hooks/src/config.rs b/crates/codegen/xai-grok-hooks/src/config.rs index 74a31c7..c1962cd 100644 --- a/crates/codegen/xai-grok-hooks/src/config.rs +++ b/crates/codegen/xai-grok-hooks/src/config.rs @@ -7,44 +7,57 @@ use crate::error::HookError; use crate::event::HookEventName; use crate::matcher::HookMatcher; -/// The parsed `hooks` object from a compatible JSON settings file. -/// -/// Parsing is lenient: unrecognized event names are skipped (not errors) so a -/// `~/.claude/settings.json` with unsupported events still loads the rest. +pub use xai_grok_config::HookProvenance; + +/// Parsed `hooks` object. Unknown event names are skipped, not errors. #[derive(Debug)] pub struct HooksMap { pub events: HashMap>, pub skipped_events: Vec, } -impl HooksMap { - pub fn from_value(value: serde_json::Value) -> Result { - let raw_map: HashMap = - serde_json::from_value(value).map_err(|e| format!("invalid hooks structure: {e}"))?; +#[derive(Clone, Copy, PartialEq, Eq)] +enum GroupErrorPolicy { + Fail, + SkipEvent, +} +impl HooksMap { + fn assemble( + entries: HashMap, + mut parse_groups: impl FnMut(V) -> Result, String>, + group_errors: GroupErrorPolicy, + ) -> Result { let mut events: HashMap> = HashMap::new(); let mut skipped_events = Vec::new(); - for (key, val) in raw_map { - let event_name: HookEventName = - match serde_json::from_value(serde_json::Value::String(key.clone())) { - Ok(name) => name, - Err(_) => { - skipped_events.push(key); - continue; - } - }; - - let matcher_groups: Vec = match serde_json::from_value(val) { - Ok(groups) => groups, - Err(e) => { - return Err(format!("invalid matcher groups for event '{key}': {e}")); + for (key, val) in entries { + let event_name = match HookEventName::parse_key(&key) { + Some(name) => name, + None => { + skipped_events.push(key); + continue; } }; - // Aliases (e.g. `SubagentEnd`) can parse to one event, so merge - // groups rather than insert, which would drop all but one. - events.entry(event_name).or_default().extend(matcher_groups); + match parse_groups(val) { + Ok(groups) => events.entry(event_name).or_default().extend(groups), + Err(detail) => match group_errors { + GroupErrorPolicy::Fail => { + return Err(format!( + "invalid matcher groups for event '{key}': {detail}" + )); + } + GroupErrorPolicy::SkipEvent => { + tracing::warn!( + event = %key, + error = %detail, + "hooks: skipping malformed event in config layer (other events still load)" + ); + skipped_events.push(key); + } + }, + } } Ok(HooksMap { @@ -52,6 +65,30 @@ impl HooksMap { skipped_events, }) } + + /// Parse a `hooks` object from JSON. A malformed event fails the whole parse. + pub fn from_value(value: serde_json::Value) -> Result { + let entries: HashMap = + serde_json::from_value(value).map_err(|e| format!("invalid hooks structure: {e}"))?; + Self::assemble( + entries, + |v| serde_json::from_value(v).map_err(|e| e.to_string()), + GroupErrorPolicy::Fail, + ) + } + + /// Parse a `hooks` table from TOML. Unlike [`Self::from_value`], a malformed + /// event is skipped so one bad event can't drop the layer. + pub fn from_toml_value(value: toml::Value) -> Result { + let entries: HashMap = value + .try_into() + .map_err(|e: toml::de::Error| format!("invalid hooks structure: {e}"))?; + Self::assemble( + entries, + |v| v.try_into().map_err(|e: toml::de::Error| e.to_string()), + GroupErrorPolicy::SkipEvent, + ) + } } #[derive(Debug, Deserialize)] @@ -69,15 +106,13 @@ pub struct RawHandler { pub url: Option, /// Seconds (converted to milliseconds internally). pub timeout: Option, - /// Extra env vars for the hook process; merged into [`HookSpec::extra_env`] - /// (see its rustdoc for precedence and reserved-key stripping). + /// Extra env vars, merged into [`HookSpec::extra_env`]. #[serde(default, deserialize_with = "deserialize_optional_string_map")] pub env: HashMap, } -/// Accepts `null`, an absent field, or a string map. Serde otherwise rejects an -/// explicit `"env": null` for a `HashMap` field even with `#[serde(default)]`; -/// treating `null` as "no env" matches user intent. +/// Treat `null` or an absent field as an empty map (serde otherwise rejects +/// `null` for a `HashMap`). fn deserialize_optional_string_map<'de, D>(de: D) -> Result, D::Error> where D: serde::Deserializer<'de>, @@ -104,8 +139,7 @@ fn default_timeout_ms(event: crate::event::HookEventName) -> u64 { } } -/// The validated handler kind. `RawHandler::handler_type` keeps the untrusted -/// string; parsing validates it into this so consumers dispatch exhaustively. +/// The validated handler kind. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum HandlerType { @@ -122,60 +156,99 @@ impl HandlerType { } } -/// A validated hook specification, ready for use by the dispatcher. +impl std::str::FromStr for HandlerType { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "command" => Ok(Self::Command), + "http" => Ok(Self::Http), + _ => Err(()), + } + } +} + +/// A validated hook specification, ready for the dispatcher. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HookSpec { pub name: String, pub event: HookEventName, pub handler_type: HandlerType, - /// Raw pattern as written, kept for `/hooks-list` display (the compiled form - /// is [`matcher`](HookSpec::matcher)). + /// Pattern as written; the compiled form is `matcher`. pub configured_matcher: Option, #[serde(skip)] pub matcher: Option, pub enabled: bool, - /// Executable path (command handlers), post-expansion: parse-time-resolvable - /// `$VAR` refs are substituted, unresolved/modifier forms (`${VAR:-x}`) kept - /// for the runner's `sh -c` branch. Unlike [`url`](HookSpec::url), commands - /// are NOT re-expanded at run time, so only `sh -c` sees mid-session env - /// changes. Display via [`command_raw`](HookSpec::command_raw) so resolved - /// secrets never leak. + /// Command path, env-expanded; unresolved/modifier forms kept for the runner's + /// `sh -c` branch. Not re-expanded at run time. Display via `command_raw`. pub command: Option, - /// Pre-expansion source for `command`; use it for display so resolved `env` - /// values (possibly secrets) never leak past the runner. + /// Pre-expansion `command` for display, so resolved secrets never leak. pub command_raw: Option, - /// URL endpoint (http handlers), post-expanded like [`command`](HookSpec::command). - /// The HTTP runner re-expands it at run time before SSRF validation, so plugin - /// URLs referencing later-injected `extra_env` keys resolve: mid-session env - /// changes take effect for URLs but not commands (deliberate asymmetry). - /// Display via [`url_raw`](HookSpec::url_raw). + /// URL (http handlers), env-expanded. Unlike `command`, the HTTP runner + /// re-expands at run time before SSRF validation (deliberate asymmetry). pub url: Option, - /// Pre-expansion source for `url`, for display; see [`command_raw`](HookSpec::command_raw). + /// Pre-expansion `url` for display; see `command_raw`. pub url_raw: Option, pub timeout_ms: u64, pub source_dir: PathBuf, - /// Extra environment variables injected into the hook process. - /// - /// Sources, lowest to highest precedence: - /// - /// 1. The user-declared `env` map (populated by [`parse_hook_file`]). - /// Runner-reserved keys (`GROK_HOOK_EVENT`, `GROK_HOOK_NAME`, - /// `GROK_SESSION_ID`, `GROK_WORKSPACE_ROOT`, `CLAUDE_PROJECT_DIR`) are - /// stripped at load time with a tracing warning. - /// 2. Plugin-injected vars merged by the plugin adapter - /// (`xai-grok-agent::plugins::hooks_adapter`): `GROK_PLUGIN_ROOT`, - /// `CLAUDE_PLUGIN_ROOT`, `GROK_PLUGIN_DATA`, `CLAUDE_PLUGIN_DATA`, which - /// override any user values for those keys. - /// 3. Runner-injected vars applied at spawn time AFTER `extra_env`, so they - /// always win even if a reserved key leaks through the layers above. This - /// is a security property: the child must see authentic identity/event - /// signals, never spoofed values. See the regression test - /// `runner_injected_vars_override_extra_env_at_spawn` in - /// `tests/integration.rs`. - /// - /// Besides being passed to the child, this map is consulted by the load-time - /// expansion of `command` and `url` (see [`crate::env_expand`]). + /// Env injected into the hook process, and consulted by load-time `command`/ + /// `url` expansion. Precedence low→high: user `env` (reserved keys stripped) < + /// plugin-injected < runner-injected at spawn (authentic identity always wins). pub extra_env: std::collections::HashMap, + /// The hook's origin and single source of truth for classification: `File` + /// (JSON files, agent frontmatter), a config tier, or `Plugin`. `#[serde(default)]` + /// reads pre-field wire specs as `File`. + #[serde(default)] + pub layer: HookProvenance, +} + +/// Namespace prefixes stamped on hook names, matched by [`hook_origin`]. Shared +/// so a rename can't silently reclassify a tier. +pub const GLOBAL_HOOK_PREFIX: &str = "global/"; +pub const PROJECT_HOOK_PREFIX: &str = "project/"; +pub const PLUGIN_HOOK_PREFIX: &str = "plugin/"; +pub const AGENT_HOOK_PREFIX: &str = "agent:"; + +/// A hook's classified origin for display and telemetry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HookOrigin { + SystemManaged, + Managed, + Requirements, + UserConfig, + UserFile, + ProjectFile, + Plugin, + Agent, + Unknown, +} + +/// Classify a hook's origin from [`HookProvenance`], falling back to the name +/// prefix for `File`-tier hooks. +pub fn hook_origin(spec: &HookSpec) -> HookOrigin { + match spec.layer { + HookProvenance::SystemManaged => HookOrigin::SystemManaged, + HookProvenance::Managed => HookOrigin::Managed, + HookProvenance::Requirements => HookOrigin::Requirements, + HookProvenance::User => HookOrigin::UserConfig, + HookProvenance::Plugin => HookOrigin::Plugin, + HookProvenance::Unknown => HookOrigin::Unknown, + HookProvenance::File => { + let name = spec.name.as_str(); + if name.starts_with(GLOBAL_HOOK_PREFIX) { + HookOrigin::UserFile + } else if name.starts_with(PROJECT_HOOK_PREFIX) { + HookOrigin::ProjectFile + } else if name.starts_with(AGENT_HOOK_PREFIX) { + HookOrigin::Agent + } else if name.starts_with(PLUGIN_HOOK_PREFIX) { + // Defensive: a plugin hook whose adapter didn't stamp `layer`. + HookOrigin::Plugin + } else { + HookOrigin::Unknown + } + } + } } /// Parse hooks from a JSON value (e.g. from agent definition frontmatter). @@ -189,24 +262,104 @@ pub fn parse_hooks_from_value( parse_hooks_from_value_with_dir(hooks, source_name, std::path::Path::new(".")) } -/// Like `parse_hooks_from_value` but with an explicit `source_dir` for -/// resolving relative command paths. +/// [`parse_hooks_from_value`] with an explicit `source_dir`. Parses the decoded +/// value directly (no re-parse round-trip); a malformed event is a hard error. pub fn parse_hooks_from_value_with_dir( hooks: &serde_json::Value, source_name: &str, source_dir: &Path, ) -> (Vec, Vec) { - let wrapper = serde_json::json!({ "hooks": hooks }); - let (mut specs, errors) = - parse_hook_file(&wrapper.to_string(), std::path::Path::new(source_name)); - for spec in &mut specs { - spec.source_dir = source_dir.to_path_buf(); + let error_path = Path::new(source_name); + let hooks_map = match HooksMap::from_value(hooks.clone()) { + Ok(map) => map, + Err(detail) => { + return ( + Vec::new(), + vec![HookError::ParseFile { + path: error_path.to_path_buf(), + detail, + }], + ); + } + }; + if !hooks_map.skipped_events.is_empty() { + tracing::warn!( + source = %source_name, + skipped = ?hooks_map.skipped_events, + "hooks: skipped unrecognized event names (check for typos)" + ); } - (specs, errors) + + let name_prefix = error_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + build_specs( + hooks_map, + SpecContext { + name_prefix, + source_dir, + error_path, + provenance: HookProvenance::File, + }, + ) +} + +/// Build specs from config-layer `hooks` blocks, tagging each with its layer's +/// `source_name`. Layers arrive highest-authority-first and specs preserve that +/// order, so the caller's dedup keeps the higher-authority copy. Relative commands +/// resolve against each layer's own directory; a layer that fails to parse is +/// recorded and skipped, the rest still load. +pub fn parse_hooks_from_config_layers( + layers: &[xai_grok_config::HookConfigLayer], +) -> (Vec, Vec) { + let home = xai_grok_config::user_grok_home(); + let mut all_specs = Vec::new(); + let mut all_errors = Vec::new(); + + for layer in layers { + let source_name = layer.source_name(); + let error_path = layer.path(); + // Resolve relative commands against the layer's own dir, not the user home. + let source_dir = match error_path.parent() { + Some(dir) if !dir.as_os_str().is_empty() => dir.to_path_buf(), + _ => home.clone().unwrap_or_else(|| PathBuf::from(".")), + }; + let hooks_map = match HooksMap::from_toml_value(layer.hooks().clone()) { + Ok(map) => map, + Err(detail) => { + all_errors.push(HookError::ParseFile { + path: error_path.to_path_buf(), + detail, + }); + continue; + } + }; + if !hooks_map.skipped_events.is_empty() { + tracing::warn!( + source = %source_name, + skipped = ?hooks_map.skipped_events, + "hooks: skipped unrecognized or malformed events in config layer" + ); + } + let (specs, errors) = build_specs( + hooks_map, + SpecContext { + name_prefix: source_name, + source_dir: &source_dir, + error_path, + provenance: layer.provenance(), + }, + ); + all_specs.extend(specs); + all_errors.extend(errors); + } + + (all_specs, all_errors) } pub fn parse_hook_file(content: &str, file_path: &Path) -> (Vec, Vec) { - let mut specs = Vec::new(); + let specs = Vec::new(); let mut errors = Vec::new(); let top_level: serde_json::Value = match serde_json::from_str(content) { @@ -250,119 +403,52 @@ pub fn parse_hook_file(content: &str, file_path: &Path) -> (Vec, Vec) -> (Vec, Vec) { + let mut specs = Vec::new(); + let mut errors = Vec::new(); + + // Stable event order for reproducible output; source order kept within an event. + let mut events: Vec<(HookEventName, Vec)> = + hooks_map.events.into_iter().collect(); + events.sort_by_key(|(event, _)| *event); + for (event, matcher_groups) in events { for (group_idx, group) in matcher_groups.into_iter().enumerate() { - let matcher_pattern = group - .matcher - .as_deref() - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - - // Events with an `Ignored` matcher policy keep the configured pattern - // for display but never compile it, so the hook always fires. - let matcher_ignored = matcher_pattern.is_some() - && event.traits().matcher == crate::event::MatcherPolicy::Ignored; - if matcher_ignored { - tracing::warn!( - hook = %format!("{file_stem}:{event}[{group_idx}]"), - path = %file_path.display(), - "hooks: matcher on a {event} group is ignored (this event always fires)" - ); - } - - let compiled_matcher = match matcher_pattern.as_ref().filter(|_| !matcher_ignored) { - Some(pattern) => match HookMatcher::new(pattern) { - Ok(m) => Some(m), + let group_label = format!("{}:{event}[{group_idx}]", ctx.name_prefix); + let (configured_matcher, compiled_matcher) = + match resolve_group_matcher(group.matcher.as_deref(), event, &group_label, &ctx) { + Ok(pair) => pair, Err(e) => { - let name = format!("{file_stem}:{event}[{group_idx}]"); - errors.push(HookError::InvalidMatcher { - name, - path: file_path.to_path_buf(), - source: e, - }); + errors.push(e); continue; } - }, - None => None, - }; + }; for (hook_idx, handler) in group.hooks.into_iter().enumerate() { - let name = format!("{file_stem}:{event}[{group_idx}].hooks[{hook_idx}]"); - - // `matcher` is deliberately NOT env-expanded: `$` is the regex - // end-of-line anchor, so `$VAR` substitution would corrupt it. - - let timeout_ms = handler - .timeout - .map(|secs| secs * 1000) - .unwrap_or(default_timeout_ms(event)); - - let mut extra_env: HashMap = handler.env; - strip_reserved_env_keys(&mut extra_env, &name, file_path); - - let handler_type = match handler.handler_type.as_str() { - "command" => HandlerType::Command, - "http" => HandlerType::Http, - _ => { - errors.push(HookError::UnsupportedHandlerType { - name, - path: file_path.to_path_buf(), - handler_type: handler.handler_type, - }); - continue; - } - }; - - // Expand `command`/`url` now (`extra_env` first, then process - // env). Unset refs are preserved: command hooks defer to the - // runner, and the HTTP runner re-expands before SSRF validation - // in case `extra_env` was populated after parsing. - let (command, command_raw, url, url_raw) = match handler_type { - HandlerType::Command => { - let Some(command) = handler.command else { - errors.push(HookError::InvalidConfig { - name, - path: file_path.to_path_buf(), - detail: "command handler requires a 'command' field".into(), - }); - continue; - }; - let expanded = - crate::env_expand::expand_env_vars_with_extra(&command, &extra_env); - (Some(PathBuf::from(expanded)), Some(command), None, None) - } - HandlerType::Http => { - let Some(url) = handler.url else { - errors.push(HookError::InvalidConfig { - name, - path: file_path.to_path_buf(), - detail: "http handler requires a 'url' field".into(), - }); - continue; - }; - let expanded = - crate::env_expand::expand_env_vars_with_extra(&url, &extra_env); - (None, None, Some(expanded), Some(url)) - } - }; - - specs.push(HookSpec { - name, + let name = format!("{group_label}.hooks[{hook_idx}]"); + match build_one_spec( + handler, event, - handler_type, - configured_matcher: matcher_pattern.clone(), - matcher: compiled_matcher.clone(), - enabled: true, - command, - command_raw, - url, - url_raw, - timeout_ms, - source_dir: source_dir.clone(), - extra_env, - }); + name, + configured_matcher.clone(), + compiled_matcher.clone(), + &ctx, + ) { + Ok(spec) => specs.push(spec), + Err(e) => errors.push(e), + } } } } @@ -370,11 +456,128 @@ pub fn parse_hook_file(content: &str, file_path: &Path) -> (Vec, Vec, + event: HookEventName, + group_label: &str, + ctx: &SpecContext<'_>, +) -> Result<(Option, Option), HookError> { + let configured = group_matcher + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + if configured.is_some() && event.traits().matcher == crate::event::MatcherPolicy::Ignored { + tracing::warn!( + hook = %group_label, + path = %ctx.error_path.display(), + "hooks: matcher on a {event} group is ignored (this event always fires)" + ); + return Ok((configured, None)); + } + + let compiled = match configured.as_deref() { + Some(pattern) => { + Some( + HookMatcher::new(pattern).map_err(|source| HookError::InvalidMatcher { + name: group_label.to_string(), + path: ctx.error_path.to_path_buf(), + source, + })?, + ) + } + None => None, + }; + Ok((configured, compiled)) +} + +/// Per-call constants shared by every group and handler in one [`build_specs`]. +struct SpecContext<'a> { + /// Labels specs as `"{name_prefix}:{event}[..]"` (file stem or config `source_name`). + name_prefix: &'a str, + source_dir: &'a Path, + error_path: &'a Path, + provenance: HookProvenance, +} + +/// Build one [`HookSpec`] from a handler entry, or the [`HookError`] preventing it. +/// `command`/`url` are env-expanded (unset refs kept for the runner); `matcher` is +/// not, since `$` is the regex end anchor. +fn build_one_spec( + handler: RawHandler, + event: HookEventName, + name: String, + configured_matcher: Option, + compiled_matcher: Option, + ctx: &SpecContext<'_>, +) -> Result { + let timeout_ms = handler + .timeout + // Untrusted config value: saturate rather than overflow (debug panic / + // release wrap) on an absurd timeout. + .map(|secs| secs.saturating_mul(1000)) + .unwrap_or(default_timeout_ms(event)); + + let mut extra_env: HashMap = handler.env; + strip_reserved_env_keys(&mut extra_env, &name, ctx.error_path); + + let handler_type = match handler.handler_type.parse::() { + Ok(ht) => ht, + Err(()) => { + return Err(HookError::UnsupportedHandlerType { + name, + path: ctx.error_path.to_path_buf(), + handler_type: handler.handler_type, + }); + } + }; + + let (command, command_raw, url, url_raw) = match handler_type { + HandlerType::Command => { + let Some(command) = handler.command else { + return Err(HookError::InvalidConfig { + name, + path: ctx.error_path.to_path_buf(), + detail: "command handler requires a 'command' field".into(), + }); + }; + let expanded = crate::env_expand::expand_env_vars_with_extra(&command, &extra_env); + (Some(PathBuf::from(expanded)), Some(command), None, None) + } + HandlerType::Http => { + let Some(url) = handler.url else { + return Err(HookError::InvalidConfig { + name, + path: ctx.error_path.to_path_buf(), + detail: "http handler requires a 'url' field".into(), + }); + }; + let expanded = crate::env_expand::expand_env_vars_with_extra(&url, &extra_env); + (None, None, Some(expanded), Some(url)) + } + }; + + Ok(HookSpec { + name, + event, + handler_type, + configured_matcher, + matcher: compiled_matcher, + enabled: true, + command, + command_raw, + url, + url_raw, + timeout_ms, + source_dir: ctx.source_dir.to_path_buf(), + extra_env, + layer: ctx.provenance, + }) +} + +/// Strip user `env` entries that would shadow runner-reserved keys, with a warning. fn strip_reserved_env_keys( extra_env: &mut HashMap, spec_name: &str, @@ -397,6 +600,78 @@ mod tests { use super::*; use crate::test_support::with_env_var; + fn config_layer(source_name: &str, toml_src: &str) -> xai_grok_config::HookConfigLayer { + let value: toml::Value = toml::from_str(toml_src).unwrap(); + let hooks = value.get("hooks").cloned().unwrap(); + xai_grok_config::HookConfigLayer::new( + xai_grok_config::HookProvenance::Managed, + source_name, + hooks, + ) + } + + #[test] + fn config_layer_hook_parses_like_the_json_path() { + let layer = config_layer( + "managed", + "[[hooks.PreToolUse]]\nmatcher = \"Bash\"\n[[hooks.PreToolUse.hooks]]\ntype = \"command\"\ncommand = \"bin/check.sh\"\ntimeout = 2\n", + ); + let (specs, errors) = parse_hooks_from_config_layers(std::slice::from_ref(&layer)); + assert!(errors.is_empty(), "unexpected errors: {errors:?}"); + assert_eq!(specs.len(), 1); + let s = &specs[0]; + assert_eq!(s.event, HookEventName::PreToolUse); + assert_eq!(s.handler_type, HandlerType::Command); + assert_eq!(s.timeout_ms, 2000); + assert_eq!(s.layer, HookProvenance::Managed); + assert!(s.name.starts_with("managed:"), "got {}", s.name); + } + + #[test] + fn config_layer_keeps_valid_events_when_one_is_malformed() { + // A config layer skips a malformed event and keeps the rest, unlike the + // JSON path which fails the whole file. + let layer = config_layer( + "managed", + "hooks.PreToolUse = \"oops\"\n[[hooks.PostToolUse]]\n[[hooks.PostToolUse.hooks]]\ntype = \"command\"\ncommand = \"ok.sh\"\n", + ); + let (specs, _errors) = parse_hooks_from_config_layers(std::slice::from_ref(&layer)); + assert_eq!(specs.len(), 1); + assert_eq!(specs[0].event, HookEventName::PostToolUse); + } + + #[test] + fn config_layers_additive_and_dedup_keeps_higher_authority() { + let mk = |src: &str, prov, cmd: &str| { + let toml_src = format!( + "[[PreToolUse]]\n[[PreToolUse.hooks]]\ntype = \"command\"\ncommand = \"{cmd}\"\n" + ); + xai_grok_config::HookConfigLayer::new( + prov, + src, + toml::from_str::(&toml_src).unwrap(), + ) + }; + + // Distinct commands are additive; an identical command dedupes to the + // higher-authority (first-listed) copy. + use xai_grok_config::HookProvenance::{Managed, User}; + let (additive, _) = parse_hooks_from_config_layers(&[ + mk("managed", Managed, "m.sh"), + mk("user", User, "u.sh"), + ]); + assert_eq!(additive.len(), 2); + + let (dup, _) = parse_hooks_from_config_layers(&[ + mk("managed", Managed, "same.sh"), + mk("user", User, "same.sh"), + ]); + let registry = crate::discovery::registry_from_specs_deduped(dup); + let pre = registry.hooks_for(HookEventName::PreToolUse); + assert_eq!(pre.len(), 1); + assert!(pre[0].name.starts_with("managed:"), "got {}", pre[0].name); + } + #[test] fn parse_claude_format_single_hook() { let json = r#"{ diff --git a/crates/codegen/xai-grok-hooks/src/discovery.rs b/crates/codegen/xai-grok-hooks/src/discovery.rs index 2595995..d3c437c 100644 --- a/crates/codegen/xai-grok-hooks/src/discovery.rs +++ b/crates/codegen/xai-grok-hooks/src/discovery.rs @@ -63,34 +63,37 @@ impl HookRegistry { } } + /// Flatten the registry into a spec list in [`HookEventName::ALL`] order, so + /// rebuilding from the result is stable regardless of `HashMap` iteration. + pub fn into_specs(self) -> Vec { + let mut hooks = self.hooks; + let mut out = Vec::new(); + for event in HookEventName::ALL { + if let Some(specs) = hooks.remove(event) { + out.extend(specs); + } + } + // Defensive: `ALL` covers every variant, but keep leftovers in a stable order. + if !hooks.is_empty() { + let mut leftover: Vec<(HookEventName, Vec)> = hooks.into_iter().collect(); + // Typed order, not `Display` (which collapses SubagentStop/SubagentEnd). + leftover.sort_by_key(|(event, _)| *event); + for (_, specs) in leftover { + out.extend(specs); + } + } + out + } + pub fn remove_by_prefix(&mut self, prefix: &str) { for specs in self.hooks.values_mut() { specs.retain(|s| !s.name.starts_with(prefix)); } } - /// All event types in canonical display order. - const ALL_EVENTS: &[HookEventName] = &[ - HookEventName::SessionStart, - HookEventName::UserPromptSubmit, - HookEventName::PreToolUse, - HookEventName::PostToolUse, - HookEventName::PostToolUseFailure, - HookEventName::PermissionDenied, - HookEventName::Stop, - HookEventName::StopFailure, - HookEventName::Notification, - HookEventName::SubagentStart, - HookEventName::SubagentStop, - HookEventName::SubagentEnd, - HookEventName::PreCompact, - HookEventName::PostCompact, - HookEventName::SessionEnd, - ]; - pub fn all_hooks(&self) -> Vec<&HookSpec> { let mut all = Vec::new(); - for event in Self::ALL_EVENTS { + for event in HookEventName::ALL { all.extend(self.hooks_for(*event)); } all @@ -125,8 +128,7 @@ impl HookRegistry { #[derive(Debug, Clone)] pub enum HookSource<'a> { - /// A JSON settings file (e.g. `~/.claude/settings.json`); only its `hooks` - /// key is used. + /// A JSON settings file; only its `hooks` key is used. SettingsFile(&'a Path), /// A directory of `*.json` hook files (e.g. `~/.grok/hooks/`). Directory(&'a Path), @@ -140,6 +142,35 @@ pub fn load_hooks_from_sources( global_sources: &[HookSource<'_>], project_sources: &[HookSource<'_>], ) -> (HookRegistry, Vec) { + let (specs, errors) = collect_specs_from_sources(global_sources, project_sources); + let registry = registry_from_specs_deduped(specs); + tracing::info!( + total_hooks = registry.len(), + session_start = registry.hooks_for(HookEventName::SessionStart).len(), + pre_tool = registry.hooks_for(HookEventName::PreToolUse).len(), + post_tool = registry.hooks_for(HookEventName::PostToolUse).len(), + session_end = registry.hooks_for(HookEventName::SessionEnd).len(), + stop = registry.hooks_for(HookEventName::Stop).len(), + notification = registry.hooks_for(HookEventName::Notification).len(), + user_prompt_submit = registry.hooks_for(HookEventName::UserPromptSubmit).len(), + subagent_start = registry.hooks_for(HookEventName::SubagentStart).len(), + subagent_stop = registry.hooks_for(HookEventName::SubagentStop).len() + + registry.hooks_for(HookEventName::SubagentEnd).len(), + "hooks: discovery complete" + ); + + (registry, errors) +} + +/// Load hook specs from global and project sources WITHOUT deduplicating, so a +/// caller can combine them with specs from other origins (e.g. config layers) and +/// run a single dedup pass. Global specs are prefixed `global/` and project specs +/// `project/`; global specs precede project specs so a later first-wins dedup +/// keeps the global copy of an identical duplicate. +pub fn collect_specs_from_sources( + global_sources: &[HookSource<'_>], + project_sources: &[HookSource<'_>], +) -> (Vec, Vec) { tracing::debug!( global_sources = global_sources.len(), project_sources = project_sources.len(), @@ -152,7 +183,7 @@ pub fn load_hooks_from_sources( for source in global_sources { let (mut specs, errors) = load_from_source(source); for spec in &mut specs { - spec.name = format!("global/{}", spec.name); + spec.name = format!("{}{}", crate::config::GLOBAL_HOOK_PREFIX, spec.name); } tracing::debug!( source = ?source, @@ -166,7 +197,7 @@ pub fn load_hooks_from_sources( for source in project_sources { let (mut specs, errors) = load_from_source(source); for spec in &mut specs { - spec.name = format!("project/{}", spec.name); + spec.name = format!("{}{}", crate::config::PROJECT_HOOK_PREFIX, spec.name); } tracing::debug!( source = ?source, @@ -177,15 +208,18 @@ pub fn load_hooks_from_sources( all_errors.extend(errors); } - // Deduplicate across sources on (canonical event, command_raw, url_raw, - // configured_matcher) so a hook defined in several sources runs once, while - // hooks sharing a command/URL but differing by matcher all still run. The - // canonical event collapses aliases (`SubagentStop`/`SubagentEnd`). Global - // hooks win because they are loaded first. + (all_specs, all_errors) +} + +/// Build a registry from specs, deduping on (canonical event, command_raw, +/// url_raw, configured_matcher) so a hook from several origins runs once; earlier +/// specs win, so callers place higher-authority first. `timeout_ms`/`extra_env` +/// are intentionally excluded from the key. +pub fn registry_from_specs_deduped(specs: Vec) -> HookRegistry { let mut hooks: HashMap> = HashMap::new(); let mut seen_content: std::collections::HashSet<(HookEventName, String, String, String)> = std::collections::HashSet::new(); - for spec in all_specs { + for spec in specs { let key = ( spec.event.canonical(), spec.command_raw.clone().unwrap_or_default(), @@ -203,24 +237,7 @@ pub fn load_hooks_from_sources( ); } } - - let registry = HookRegistry { hooks }; - tracing::info!( - total_hooks = registry.len(), - session_start = registry.hooks_for(HookEventName::SessionStart).len(), - pre_tool = registry.hooks_for(HookEventName::PreToolUse).len(), - post_tool = registry.hooks_for(HookEventName::PostToolUse).len(), - session_end = registry.hooks_for(HookEventName::SessionEnd).len(), - stop = registry.hooks_for(HookEventName::Stop).len(), - notification = registry.hooks_for(HookEventName::Notification).len(), - user_prompt_submit = registry.hooks_for(HookEventName::UserPromptSubmit).len(), - subagent_start = registry.hooks_for(HookEventName::SubagentStart).len(), - subagent_stop = registry.hooks_for(HookEventName::SubagentStop).len() - + registry.hooks_for(HookEventName::SubagentEnd).len(), - "hooks: discovery complete" - ); - - (registry, all_errors) + HookRegistry { hooks } } /// Convenience wrapper: load hooks from a single global directory and optional @@ -364,60 +381,14 @@ mod tests { .to_string() } - /// Drift guard for the hand-maintained `ALL_EVENTS`: a new `HookEventName` - /// variant breaks the exhaustive match below, then fails the assertion until - /// it is added to `ALL_EVENTS`, so no event vanishes from the flat listing. - #[test] - fn all_events_lists_every_variant() { - let every_variant = [ - HookEventName::SessionStart, - HookEventName::UserPromptSubmit, - HookEventName::PreToolUse, - HookEventName::PostToolUse, - HookEventName::PostToolUseFailure, - HookEventName::PermissionDenied, - HookEventName::Stop, - HookEventName::StopFailure, - HookEventName::Notification, - HookEventName::SubagentStart, - HookEventName::SubagentStop, - HookEventName::SubagentEnd, - HookEventName::PreCompact, - HookEventName::PostCompact, - HookEventName::SessionEnd, - ]; - for event in every_variant { - match event { - HookEventName::SessionStart - | HookEventName::UserPromptSubmit - | HookEventName::PreToolUse - | HookEventName::PostToolUse - | HookEventName::PostToolUseFailure - | HookEventName::PermissionDenied - | HookEventName::Stop - | HookEventName::StopFailure - | HookEventName::Notification - | HookEventName::SubagentStart - | HookEventName::SubagentStop - | HookEventName::SubagentEnd - | HookEventName::PreCompact - | HookEventName::PostCompact - | HookEventName::SessionEnd => {} - } - assert!( - HookRegistry::ALL_EVENTS.contains(&event), - "{event} is missing from ALL_EVENTS" - ); - } - } - /// Drift guard: gate events must match the `blockingEvents` the agent /// advertises (extensions/hooks.rs). A new gate event fails here. #[test] fn gate_events_are_the_known_set() { use crate::event::GateKind; - // Canonicalize first: `traits()` is unreachable on alias variants. - let gates: std::collections::HashSet<_> = HookRegistry::ALL_EVENTS + // Canonicalize first to dedup alias spellings into one set entry + // (`traits()` itself already canonicalizes, so it's safe on aliases). + let gates: std::collections::HashSet<_> = HookEventName::ALL .iter() .map(|e| e.canonical()) .filter(|e| e.traits().gate != GateKind::Observe) @@ -923,6 +894,7 @@ mod tests { timeout_ms: 5_000, source_dir: PathBuf::from("/tmp"), extra_env: Default::default(), + layer: crate::config::HookProvenance::File, } } diff --git a/crates/codegen/xai-grok-hooks/src/dispatcher.rs b/crates/codegen/xai-grok-hooks/src/dispatcher.rs index d396b6c..1d4963d 100644 --- a/crates/codegen/xai-grok-hooks/src/dispatcher.rs +++ b/crates/codegen/xai-grok-hooks/src/dispatcher.rs @@ -551,6 +551,7 @@ mod tests { timeout_ms: 5000, source_dir: PathBuf::from("/tmp"), extra_env: HashMap::new(), + layer: crate::config::HookProvenance::File, } } diff --git a/crates/codegen/xai-grok-hooks/src/event.rs b/crates/codegen/xai-grok-hooks/src/event.rs index 59a5cb6..d6d46a7 100644 --- a/crates/codegen/xai-grok-hooks/src/event.rs +++ b/crates/codegen/xai-grok-hooks/src/event.rs @@ -3,112 +3,187 @@ use serde::Serialize; /// Maximum serialized size for `toolInput` or `toolResult` in bytes (128 KB). pub const MAX_PAYLOAD_SIZE: usize = 128 * 1024; -/// Hook event types. -/// -/// Deserialization accepts PascalCase, snake_case, camelCase, and per-operation -/// aliases (e.g. `beforeShellExecution` maps to `PreToolUse`); see the `Deserialize` impl. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum HookEventName { - SessionStart, - SessionEnd, +/// Generates [`HookEventName`] and its `Deserialize`/`parse_key`, `Display`, +/// `traits()`, and `ALL` from one table, so adding an event is a single row. +/// Per row: `display` is the canonical rendering (may differ from the variant's +/// snake_case, e.g. `SubagentEnd` -> `subagent_stop`); `aliases` are the exact +/// `Deserialize` spellings (disjoint across variants); `traits` is the +/// `(gate, matcher, hub)` triple. `Serialize` stays derived snake_case (wire unchanged). +macro_rules! hook_events { + ($( + $(#[$vmeta:meta])* + $variant:ident { + display: $display:literal, + aliases: [$($alias:literal),* $(,)?], + traits: ($gate:ident, $matcher:ident, $hub:literal $(,)?), + } + ),* $(,)?) => { + /// Hook event types. `Ord` follows table order (stable, keeps the + /// `SubagentStop`/`SubagentEnd` aliases distinct unlike `Display`). + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] + #[serde(rename_all = "snake_case")] + pub enum HookEventName { + $($(#[$vmeta])* $variant),* + } + + impl HookEventName { + /// Every variant, in canonical display order. + pub const ALL: &'static [HookEventName] = &[$(HookEventName::$variant),*]; + + /// Source of truth for known spellings, behind `Deserialize` and `parse_key`. + fn from_key_str(s: &str) -> Option { + match s { + $($($alias)|* => Some(Self::$variant),)* + _ => None, + } + } + + /// The event's dispatch traits, generated exhaustively from the table. + pub fn traits(self) -> EventTraits { + use GateKind::*; + use MatcherPolicy::*; + match self { + $(Self::$variant => EventTraits { + gate: $gate, + matcher: $matcher, + hub_forward: $hub, + },)* + } + } + } + + impl std::fmt::Display for HookEventName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { $(Self::$variant => $display,)* }) + } + } + + impl<'de> serde::Deserialize<'de> for HookEventName { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = ::deserialize(deserializer)?; + Self::from_key_str(&s).ok_or_else(|| { + // Built from the table so it can't drift from the accepted set. + let known = Self::ALL + .iter() + .map(|e| e.to_string()) + .collect::>() + .into_iter() + .collect::>() + .join(", "); + serde::de::Error::custom(format!( + "unknown hook event: '{s}'. Expected one of: {known} \ + (camelCase and per-operation aliases such as \ + beforeShellExecution are also accepted)" + )) + }) + } + } + }; +} + +// Table order is the canonical display order (drives `ALL` and `Ord`). +// Per-operation aliases map to generic `PreToolUse`/`PostToolUse`. +hook_events! { + SessionStart { + display: "session_start", + aliases: ["SessionStart", "session_start", "sessionStart"], + traits: (Observe, Tested, true), + }, + UserPromptSubmit { + display: "user_prompt_submit", + aliases: ["UserPromptSubmit", "user_prompt_submit", "beforeSubmitPrompt"], + traits: (Observe, Ignored, true), + }, + PreToolUse { + display: "pre_tool_use", + aliases: [ + "PreToolUse", + "pre_tool_use", + "preToolUse", + "beforeShellExecution", + "beforeMCPExecution", + "beforeReadFile", + ], + traits: (Tool, Tested, false), + }, + PostToolUse { + display: "post_tool_use", + aliases: [ + "PostToolUse", + "post_tool_use", + "postToolUse", + "afterShellExecution", + "afterMCPExecution", + "afterFileEdit", + "afterAgentResponse", + "afterAgentThought", + ], + traits: (Observe, Tested, true), + }, + PostToolUseFailure { + display: "post_tool_use_failure", + aliases: ["PostToolUseFailure", "post_tool_use_failure", "postToolUseFailure"], + traits: (Observe, Tested, true), + }, + PermissionDenied { + display: "permission_denied", + aliases: ["PermissionDenied", "permission_denied", "permissionDenied"], + traits: (Observe, Tested, true), + }, /// Fires on a genuine turn-end with stop decision control (a hook can block); /// not on user interrupts (API-error turns fire `StopFailure`); observe-only at session end. - Stop, + Stop { + display: "stop", + aliases: ["Stop", "stop"], + traits: (Stop, Ignored, true), + }, /// Fires when the turn ends due to an API error. Output and exit code are ignored. - StopFailure, - - PreToolUse, - PostToolUse, - PostToolUseFailure, - PermissionDenied, - - UserPromptSubmit, - Notification, - - SubagentStart, - SubagentStop, - SubagentEnd, - - PreCompact, - PostCompact, -} - -impl<'de> serde::Deserialize<'de> for HookEventName { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - match s.as_str() { - // PascalCase (native) + snake_case + camelCase (third-party compat). - // Per-operation hook names (beforeShellExecution, afterFileEdit, etc.) - // map to our generic PreToolUse/PostToolUse; the hook script receives the - // tool name in JSON input and can filter, or use the `matcher` field. - "SessionStart" | "session_start" | "sessionStart" => Ok(Self::SessionStart), - "PreToolUse" - | "pre_tool_use" - | "preToolUse" - | "beforeShellExecution" - | "beforeMCPExecution" - | "beforeReadFile" => Ok(Self::PreToolUse), - "PostToolUse" - | "post_tool_use" - | "postToolUse" - | "afterShellExecution" - | "afterMCPExecution" - | "afterFileEdit" - | "afterAgentResponse" - | "afterAgentThought" => Ok(Self::PostToolUse), - "PostToolUseFailure" | "post_tool_use_failure" | "postToolUseFailure" => { - Ok(Self::PostToolUseFailure) - } - "SessionEnd" | "session_end" | "sessionEnd" => Ok(Self::SessionEnd), - "Stop" | "stop" => Ok(Self::Stop), - "StopFailure" | "stop_failure" | "stopFailure" => Ok(Self::StopFailure), - "Notification" | "notification" => Ok(Self::Notification), - "UserPromptSubmit" | "user_prompt_submit" | "beforeSubmitPrompt" => { - Ok(Self::UserPromptSubmit) - } - "PermissionDenied" | "permission_denied" | "permissionDenied" => { - Ok(Self::PermissionDenied) - } - "SubagentStart" | "subagent_start" | "subagentStart" => Ok(Self::SubagentStart), - "SubagentStop" | "subagent_stop" | "subagentStop" => Ok(Self::SubagentStop), - "SubagentEnd" | "subagent_end" | "subagentEnd" => Ok(Self::SubagentEnd), - "PreCompact" | "pre_compact" | "preCompact" => Ok(Self::PreCompact), - "PostCompact" | "post_compact" | "postCompact" => Ok(Self::PostCompact), - other => Err(serde::de::Error::custom(format!( - "unknown hook event: '{other}'. Expected one of: \ - SessionStart, PreToolUse, PostToolUse, PostToolUseFailure, \ - SessionEnd, Stop, StopFailure, Notification, UserPromptSubmit, \ - PermissionDenied, SubagentStart, SubagentStop, \ - PreCompact, PostCompact (camelCase and per-operation aliases \ - such as beforeShellExecution are also accepted)" - ))), - } - } -} - -impl std::fmt::Display for HookEventName { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::SessionStart => write!(f, "session_start"), - Self::PreToolUse => write!(f, "pre_tool_use"), - Self::PostToolUse => write!(f, "post_tool_use"), - Self::PostToolUseFailure => write!(f, "post_tool_use_failure"), - Self::SessionEnd => write!(f, "session_end"), - Self::Stop => write!(f, "stop"), - Self::StopFailure => write!(f, "stop_failure"), - Self::Notification => write!(f, "notification"), - Self::UserPromptSubmit => write!(f, "user_prompt_submit"), - Self::PermissionDenied => write!(f, "permission_denied"), - Self::SubagentStart => write!(f, "subagent_start"), - Self::SubagentStop | Self::SubagentEnd => write!(f, "subagent_stop"), - Self::PreCompact => write!(f, "pre_compact"), - Self::PostCompact => write!(f, "post_compact"), - } - } + StopFailure { + display: "stop_failure", + aliases: ["StopFailure", "stop_failure", "stopFailure"], + traits: (Observe, Tested, true), + }, + Notification { + display: "notification", + aliases: ["Notification", "notification"], + traits: (Observe, Tested, true), + }, + SubagentStart { + display: "subagent_start", + aliases: ["SubagentStart", "subagent_start", "subagentStart"], + traits: (Observe, Tested, true), + }, + SubagentStop { + display: "subagent_stop", + aliases: ["SubagentStop", "subagent_stop", "subagentStop"], + traits: (Stop, Tested, true), + }, + /// Legacy alias of `SubagentStop`: kept as a distinct variant so a hook + /// registered under either spelling round-trips, then collapsed via + /// [`HookEventName::canonical`] for dispatch and dedup. + SubagentEnd { + display: "subagent_stop", + aliases: ["SubagentEnd", "subagent_end", "subagentEnd"], + traits: (Stop, Tested, true), + }, + PreCompact { + display: "pre_compact", + aliases: ["PreCompact", "pre_compact", "preCompact"], + traits: (Observe, Tested, true), + }, + PostCompact { + display: "post_compact", + aliases: ["PostCompact", "post_compact", "postCompact"], + traits: (Observe, Tested, true), + }, + SessionEnd { + display: "session_end", + aliases: ["SessionEnd", "session_end", "sessionEnd"], + traits: (Observe, Tested, true), + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -137,9 +212,8 @@ pub struct EventTraits { } impl HookEventName { - /// Collapse alias variants to their canonical form so a registration and the fired - /// event meet on one key regardless of which spelling each used (`SubagentEnd` is an - /// alias of `SubagentStop`). + /// Collapse aliases so a registration and the fired event meet on one key + /// (`SubagentEnd` is an alias of `SubagentStop`). pub fn canonical(self) -> Self { match self { Self::SubagentEnd => Self::SubagentStop, @@ -147,33 +221,9 @@ impl HookEventName { } } - /// The event's dispatch traits. Exhaustive on purpose: a new variant fails to - /// compile until its gate, matcher, and hub forwarding are chosen here. - pub fn traits(self) -> EventTraits { - use GateKind::*; - use MatcherPolicy::*; - let t = |gate, matcher, hub_forward| EventTraits { - gate, - matcher, - hub_forward, - }; - match self.canonical() { - Self::SessionStart => t(Observe, Tested, true), - Self::SessionEnd => t(Observe, Tested, true), - Self::Stop => t(Stop, Ignored, true), - Self::StopFailure => t(Observe, Tested, true), - Self::PreToolUse => t(Tool, Tested, false), - Self::PostToolUse => t(Observe, Tested, true), - Self::PostToolUseFailure => t(Observe, Tested, true), - Self::PermissionDenied => t(Observe, Tested, true), - Self::UserPromptSubmit => t(Observe, Ignored, true), - Self::Notification => t(Observe, Tested, true), - Self::SubagentStart => t(Observe, Tested, true), - Self::SubagentStop => t(Stop, Tested, true), - Self::SubagentEnd => unreachable!("canonicalized above"), - Self::PreCompact => t(Observe, Tested, true), - Self::PostCompact => t(Observe, Tested, true), - } + /// Validate a bare event key against the accepted spellings; `None` if unknown. + pub fn parse_key(s: &str) -> Option { + Self::from_key_str(s) } } diff --git a/crates/codegen/xai-grok-hooks/src/runner/command.rs b/crates/codegen/xai-grok-hooks/src/runner/command.rs index 94a8920..1ff5ac9 100644 --- a/crates/codegen/xai-grok-hooks/src/runner/command.rs +++ b/crates/codegen/xai-grok-hooks/src/runner/command.rs @@ -834,6 +834,7 @@ mod tests { timeout_ms: 5000, source_dir: std::path::PathBuf::from(source), extra_env: std::collections::HashMap::new(), + layer: crate::config::HookProvenance::File, }; use crate::config::HandlerType; assert_eq!( @@ -876,6 +877,7 @@ mod tests { timeout_ms: 5000, source_dir: std::env::temp_dir(), extra_env: std::collections::HashMap::new(), + layer: crate::config::HookProvenance::File, } } @@ -1038,6 +1040,7 @@ mod tests { timeout_ms: 5000, source_dir: tmp.path().to_path_buf(), extra_env, + layer: crate::config::HookProvenance::File, }; let envelope = make_envelope(); @@ -1096,6 +1099,7 @@ mod tests { timeout_ms: 5000, source_dir: tmp.path().to_path_buf(), extra_env: std::collections::HashMap::new(), + layer: crate::config::HookProvenance::File, }; let envelope = make_envelope(); @@ -1225,6 +1229,7 @@ mod tests { timeout_ms: 5000, source_dir: std::env::temp_dir(), extra_env, + layer: crate::config::HookProvenance::File, }; let envelope = make_envelope(); @@ -1298,6 +1303,7 @@ mod tests { timeout_ms: 5000, source_dir: std::env::temp_dir(), extra_env, + layer: crate::config::HookProvenance::File, }; let envelope = make_envelope(); @@ -1363,6 +1369,7 @@ mod tests { timeout_ms: 5000, source_dir: tmp.path().to_path_buf(), extra_env: std::collections::HashMap::new(), + layer: crate::config::HookProvenance::File, }; let envelope = make_envelope(); diff --git a/crates/codegen/xai-grok-hooks/src/runner/http.rs b/crates/codegen/xai-grok-hooks/src/runner/http.rs index 88cae62..7fb7b8c 100644 --- a/crates/codegen/xai-grok-hooks/src/runner/http.rs +++ b/crates/codegen/xai-grok-hooks/src/runner/http.rs @@ -649,6 +649,7 @@ mod tests { timeout_ms: 1000, source_dir: std::env::temp_dir(), extra_env, + layer: crate::config::HookProvenance::File, }; let envelope = HookEventEnvelope { @@ -727,6 +728,7 @@ mod tests { timeout_ms: 500, source_dir: std::env::temp_dir(), extra_env, + layer: crate::config::HookProvenance::File, }; let envelope = HookEventEnvelope { hook_event_name: HookEventName::PreToolUse, diff --git a/crates/codegen/xai-grok-http/src/lib.rs b/crates/codegen/xai-grok-http/src/lib.rs index 0787ca7..4a6e8c1 100644 --- a/crates/codegen/xai-grok-http/src/lib.rs +++ b/crates/codegen/xai-grok-http/src/lib.rs @@ -347,13 +347,15 @@ pub fn shared_upload_client() -> reqwest::Client { /// `pool_max_idle_per_host(0)` + `http1_only()` so each request opens a new connection, and no /// connect timeout (callers bound each request with their own total timeout). The retry escape /// policy that reaches for this client to dodge a poisoned pool lives on `send_with_retry_escaping_pool`. -pub(crate) fn fresh_http1_client() -> reqwest::Client { +/// +/// Fallible: build can fail under fd/TLS pressure; the caller must not +/// panic on error (fallback policy lives at the call site). +pub(crate) fn fresh_http1_client() -> reqwest::Result { reqwest::Client::builder() .http1_only() .pool_max_idle_per_host(0) .user_agent(process_user_agent_string()) .build() - .expect("failed to build fresh HTTP/1.1 client") } /// Joins an error's `source()` chain into one string. A `reqwest::Error`'s `Display` @@ -456,7 +458,18 @@ where // Only the final attempt of a multi-attempt run escapes onto a fresh pool-less connection; a // single-attempt caller keeps the pooled client (there is no prior failure to escape). let client = if attempt > 0 && attempt + 1 == max_attempts { - fresh.get_or_insert_with(fresh_http1_client).clone() + match &fresh { + Some(c) => c.clone(), + None => match fresh_http1_client() { + Ok(c) => fresh.insert(c).clone(), + // Can't escape the pool (e.g. fd exhaustion); a pooled + // final attempt still beats aborting the process. + Err(e) => { + tracing::warn!(error = %e, "failed to build pool-escape client; final attempt stays on pooled client"); + pooled.clone() + } + }, + } } else { pooled.clone() }; diff --git a/crates/codegen/xai-grok-pager-bin/Cargo.toml b/crates/codegen/xai-grok-pager-bin/Cargo.toml index 5cb2c00..39f2d86 100644 --- a/crates/codegen/xai-grok-pager-bin/Cargo.toml +++ b/crates/codegen/xai-grok-pager-bin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xai-grok-pager-bin" -version = "0.2.111" +version = "0.2.112" edition.workspace = true license = "Apache-2.0" authors = ["xAI"] diff --git a/crates/codegen/xai-grok-pager-bin/src/main.rs b/crates/codegen/xai-grok-pager-bin/src/main.rs index c824312..f52d448 100644 --- a/crates/codegen/xai-grok-pager-bin/src/main.rs +++ b/crates/codegen/xai-grok-pager-bin/src/main.rs @@ -1416,25 +1416,24 @@ async fn run_agent_command( } } } -/// Raise the per-process file descriptor soft limit on macOS. +/// Raise the per-process fd soft limit toward the hard limit. /// -/// macOS has a conservative default soft `RLIMIT_NOFILE` (256) that is easily -/// exceeded by parallel directory walking + file copying in worktree creation, -/// stdio MCP servers, tool subprocesses, and async runtime sockets. +/// Default soft limits (256 macOS, commonly 1024 Linux) are easily exceeded: +/// each session thread's runtime costs ~3 fds, and a wide parallel subagent +/// wave adds spawn-burst transients — a 1024 limit fails with EMFILE under a +/// ~100-session wave. Targets 65536 on Linux (hard limits typically >= 1M) +/// and 8192 on macOS (`kern.maxfilesperproc` is often ~10k). No known +/// in-tree `select(2)` users (Rust std/tokio use epoll/kqueue); residual +/// third-party `FD_SETSIZE` risk is accepted — the prior 8192 cap already +/// exceeded FD_SETSIZE. /// -/// We raise the soft limit toward the hard limit, capped at 8192 to stay below -/// `FD_SETSIZE` (1024 on macOS) safety boundaries in any C dependency that may -/// still use `select(2)` -- Rust std + tokio use `kqueue`, but vendored C code -/// can corrupt the stack if it select()'s on an fd >= FD_SETSIZE. 8192 also -/// keeps fork-time fd-table iteration cheap for any child that does -/// "close all fds up to rlim_cur" on exec. -/// -/// Best-effort: silently ignores all errors (process limits can be tightened by -/// containers/cgroups and we should never block startup on a non-essential -/// optimization). -#[cfg(target_os = "macos")] +/// Best-effort: never blocks startup (containers/cgroups may pin limits). +#[cfg(unix)] fn raise_fd_limit() { + #[cfg(target_os = "macos")] const TARGET: libc::rlim_t = 8192; + #[cfg(not(target_os = "macos"))] + const TARGET: libc::rlim_t = 65536; unsafe { let mut rlim = libc::rlimit { rlim_cur: 0, @@ -1454,7 +1453,7 @@ fn raise_fd_limit() { } } } -#[cfg(not(target_os = "macos"))] +#[cfg(not(unix))] fn raise_fd_limit() {} /// Single audit point for the `Command::Dashboard` soft-subcommand. /// Sets `GROK_OPEN_DASHBOARD_AT_STARTUP=1` if the user asked for diff --git a/crates/codegen/xai-grok-pager-minimal/src/live.rs b/crates/codegen/xai-grok-pager-minimal/src/live.rs index ae8f751..e69301e 100644 --- a/crates/codegen/xai-grok-pager-minimal/src/live.rs +++ b/crates/codegen/xai-grok-pager-minimal/src/live.rs @@ -19,7 +19,7 @@ use xai_grok_pager::render::Renderable; use xai_grok_pager::scrollback::state::ScrollbackState; use xai_grok_pager::scrollback::wrappers::EntryRenderer; use xai_grok_pager::theme::Theme; -use xai_grok_pager::views::prompt_widget::PromptStyle; +use xai_grok_pager::views::prompt_widget::{PromptBg, PromptStyle}; use xai_grok_pager::views::turn_status; /// Left inset (columns) for every auxiliary live-region row: the status row, /// the info bar, the exit hint, and the todo panel — and the prompt's @@ -81,7 +81,7 @@ pub(super) fn prompt_style( chrome: true, chrome_pad_left: live_left_inset(appearance), chrome_pad_right: 0, - bg_override: Some(Color::Reset), + bg: PromptBg::Canvas(Color::Reset), accent_color_override: input_mode.accent_color(theme), border_color_override: None, prefix_override: input_mode.prefix_override(theme), @@ -511,12 +511,11 @@ fn minimal_advance_phase_timer( /// surfaces the same rich activity detail (`Run …` / `Thinking…` / /// `Waiting on subagent…` / `Retrying (attempt N)…` / `Cancelling…`), the /// per-phase + turn timers, and the "… still running" cue (running commands / -/// monitors / loops / background subagents, shown while idle or parked) — -/// instead of collapsing everything to "working…". Keyboard-only, so the -/// mouse `[stop]` / `[↓]` buttons are suppressed (`None`), and -/// `flat_background` keeps the row transparent like the rest of the live -/// region. When the widget would draw nothing (plain idle or parked, no -/// watchers) a small `minimal · /help` hint is shown instead. +/// monitors / loops / background subagents) — instead of collapsing +/// everything to "working…". Keyboard-only, so the mouse `[stop]` / `[↓]` +/// buttons are suppressed (`None`), and `flat_background` keeps the row +/// transparent like the rest of the live region. When the widget would draw +/// nothing a small `minimal · /help` hint is shown instead. fn render_minimal_status( buf: &mut Buffer, area: Rect, diff --git a/crates/codegen/xai-grok-pager-minimal/src/overlay.rs b/crates/codegen/xai-grok-pager-minimal/src/overlay.rs index ed5f75f..f271897 100644 --- a/crates/codegen/xai-grok-pager-minimal/src/overlay.rs +++ b/crates/codegen/xai-grok-pager-minimal/src/overlay.rs @@ -42,7 +42,7 @@ use xai_grok_pager::appearance::LayoutConfig; use xai_grok_pager::minimal_api; use xai_grok_pager::render::SafeBuf as _; use xai_grok_pager::theme::Theme; -use xai_grok_pager::views::prompt_widget::{PromptStyle, PromptWidget}; +use xai_grok_pager::views::prompt_widget::{PromptBg, PromptStyle, PromptWidget}; /// Which prompt-anchored dropdown is currently shown. /// @@ -799,7 +799,7 @@ fn inline_input_style(theme: &Theme) -> PromptStyle { chrome: false, chrome_pad_left: 0, chrome_pad_right: 0, - bg_override: Some(theme.bg_visual), + bg: PromptBg::Panel(theme.bg_visual), accent_color_override: None, border_color_override: None, prefix_override: None, diff --git a/crates/codegen/xai-grok-pager-minimal/src/plan.rs b/crates/codegen/xai-grok-pager-minimal/src/plan.rs index 1904cde..0d77fb4 100644 --- a/crates/codegen/xai-grok-pager-minimal/src/plan.rs +++ b/crates/codegen/xai-grok-pager-minimal/src/plan.rs @@ -26,7 +26,7 @@ use xai_grok_pager::minimal_api; use xai_grok_pager::scrollback::block::RenderBlock; use xai_grok_pager::theme::Theme; use xai_grok_pager::views::plan_approval_view::PlanApprovalFocus; -use xai_grok_pager::views::prompt_widget::PromptStyle; +use xai_grok_pager::views::prompt_widget::{PromptBg, PromptStyle}; /// The active plan-approval focus, defaulting to `Preview`. fn focus(agent: &AgentView) -> PlanApprovalFocus { @@ -227,7 +227,7 @@ fn input_style(theme: &Theme) -> PromptStyle { chrome: false, chrome_pad_left: 0, chrome_pad_right: 0, - bg_override: Some(theme.bg_visual), + bg: PromptBg::Panel(theme.bg_visual), accent_color_override: None, border_color_override: None, prefix_override: None, diff --git a/crates/codegen/xai-grok-pager-pty-harness/src/flows.rs b/crates/codegen/xai-grok-pager-pty-harness/src/flows.rs index a0c129d..aef7a00 100644 --- a/crates/codegen/xai-grok-pager-pty-harness/src/flows.rs +++ b/crates/codegen/xai-grok-pager-pty-harness/src/flows.rs @@ -87,7 +87,46 @@ pub fn seed_fake_oauth_coding_data_opted_out(content: &ContentController, user: seed_fake_oauth_with_opt_out(content, user, true); } +/// Like [`seed_fake_oauth_coding_data_opted_out`], but on a Zero Data +/// Retention team (`team_blocked_reasons` carries `BLOCKED_REASON_NO_LOGS`, +/// the shell's `GrokAuth::is_zdr_team` trigger) — locks the settings modal's +/// `coding_data_sharing` row to `ZDR` and suppresses the privacy banner. +pub fn seed_fake_oauth_zdr_team(content: &ContentController, user: &str) { + seed_fake_oauth_raw( + content, + user, + true, + ",\n \"team_name\": \"PTY ZDR Team\",\n \"team_role\": \"MEMBER\",\n \ + \"team_blocked_reasons\": [\"BLOCKED_REASON_NO_LOGS\"]", + ); +} + +/// Like [`seed_fake_oauth_coding_data_opted_out`], but as a non-admin member +/// of a (non-ZDR) team — locks the settings modal's `coding_data_sharing` +/// row to `Opt out · Admin Managed` and suppresses the privacy banner. +pub fn seed_fake_oauth_team_member(content: &ContentController, user: &str) { + seed_fake_oauth_raw( + content, + user, + true, + ",\n \"team_name\": \"PTY Team\",\n \"team_role\": \"MEMBER\"", + ); +} + fn seed_fake_oauth_with_opt_out(content: &ContentController, user: &str, opted_out: bool) { + seed_fake_oauth_raw(content, user, opted_out, ""); +} + +/// Shared auth.json template writer. `team_fields` is a raw JSON fragment +/// spliced after `coding_data_retention_opt_out` (empty = no team; field +/// names must match the shell's `GrokAuth` serde names in +/// `xai-grok-shell/src/auth/model.rs`). +fn seed_fake_oauth_raw( + content: &ContentController, + user: &str, + opted_out: bool, + team_fields: &str, +) { let grok_home = content.home().join(".grok"); std::fs::create_dir_all(&grok_home).expect("create temp .grok"); std::fs::write( @@ -104,7 +143,7 @@ fn seed_fake_oauth_with_opt_out(content: &ContentController, user: &str, opted_o "refresh_token": "pty-test-refresh-token", "oidc_issuer": "https://auth.x.ai", "oidc_client_id": "b1a00492-073a-47ea-816f-4c329264a828", - "coding_data_retention_opt_out": {opted_out} + "coding_data_retention_opt_out": {opted_out}{team_fields} }} }}"# ), diff --git a/crates/codegen/xai-grok-pager-pty-harness/src/lib.rs b/crates/codegen/xai-grok-pager-pty-harness/src/lib.rs index 599f163..c0709f6 100644 --- a/crates/codegen/xai-grok-pager-pty-harness/src/lib.rs +++ b/crates/codegen/xai-grok-pager-pty-harness/src/lib.rs @@ -42,8 +42,8 @@ pub use content::{ pub use env::pager_binary; pub use flows::{ inference_request_count, oauth_credential_ops, seed_fake_oauth, - seed_fake_oauth_coding_data_opted_out, submit_turn, wait_for_labels_absent, - wait_for_model_via_new_sessions, + seed_fake_oauth_coding_data_opted_out, seed_fake_oauth_team_member, seed_fake_oauth_zdr_team, + submit_turn, wait_for_labels_absent, wait_for_model_via_new_sessions, }; pub use host_clipboard::HostClipboardTextGuard; pub use leader::LeaderCluster; diff --git a/crates/codegen/xai-grok-pager-pty-harness/src/pty.rs b/crates/codegen/xai-grok-pager-pty-harness/src/pty.rs index 3008491..e227d7d 100644 --- a/crates/codegen/xai-grok-pager-pty-harness/src/pty.rs +++ b/crates/codegen/xai-grok-pager-pty-harness/src/pty.rs @@ -21,6 +21,7 @@ pub mod keys { pub const Q: &[u8] = b"q"; pub const DOWN: &[u8] = b"\x1b[B"; pub const UP: &[u8] = b"\x1b[A"; + pub const RIGHT: &[u8] = b"\x1b[C"; pub const PGDN: &[u8] = b"\x1b[6~"; pub const PGUP: &[u8] = b"\x1b[5~"; pub const ENTER: &[u8] = b"\r"; @@ -28,6 +29,8 @@ pub mod keys { /// Ctrl+R (0x12) — prompt history search / scrollback mouse-reporting toggle. pub const CTRL_R: &[u8] = b"\x12"; pub const ESC: &[u8] = b"\x1b"; + /// F2 (SS3 `ESC O Q`, the xterm encoding crossterm parses) — opens the settings modal. + pub const F2: &[u8] = b"\x1bOQ"; } /// One explicit environment mutation applied after the TestSandbox baseline. diff --git a/crates/codegen/xai-grok-pager-pty-harness/tests/settings_locked_row_e2e.rs b/crates/codegen/xai-grok-pager-pty-harness/tests/settings_locked_row_e2e.rs new file mode 100644 index 0000000..f6ac27f --- /dev/null +++ b/crates/codegen/xai-grok-pager-pty-harness/tests/settings_locked_row_e2e.rs @@ -0,0 +1,245 @@ +//! E2E: the settings modal's locked `Coding data sharing` row, driven off +//! the seeded auth entry through the full pipeline (auth.json → shell +//! `GrokAuth` → auth meta → `AppView::coding_data_sharing_lock()` → +//! `PagerLocalSnapshot` → render): +//! +//! - ZDR team (`team_blocked_reasons` = `BLOCKED_REASON_NO_LOGS`): the value +//! column shows exactly `ZDR` (no Opt in / Opt out), no `›` chevron; +//! expanding the row shows only "Your team has Zero Data Retention." +//! - Team non-admin (`team_role` = `MEMBER`): the value shows +//! `Opt out · Admin Managed`, no chevron; expanding shows only +//! "Managed by your team admin." +//! +//! Both accounts also suppress the welcome privacy banner even with +//! `GROK_PRIVACY_NOTICE_ROLLOUT=1` — asserted on the authenticated welcome +//! screen before opening settings. Row/input details are unit-tested in +//! `xai-grok-pager` (`views/settings_modal/tests.rs`, `locked_coding_*`); +//! this suite covers the auth-to-render pipeline. +//! +//! ```bash +//! cargo test -p xai-grok-pager-pty-harness --test settings_locked_row_e2e \ +//! -- --ignored --nocapture +//! ``` + +use std::path::Path; +use std::time::Duration; + +use anyhow::{Context, Result}; +use xai_grok_pager_pty_harness::{ + ContentController, EnvOp, PtyHarness, keys, pager_binary, seed_fake_oauth_team_member, + seed_fake_oauth_zdr_team, +}; + +const ROWS: u16 = 50; +const COLS: u16 = 120; +const BANNER_TITLE: &str = "Help improve Grok"; +const ROW_LABEL: &str = "Coding data sharing"; +const CHEVRON: &str = "\u{203A}"; // › +const ZDR_REASON: &str = "Your team has Zero Data Retention."; +const TEAM_REASON: &str = "Managed by your team admin."; +const DESCRIPTION_PREFIX: &str = "Controls whether"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore] // opt-in: spawns the real pager binary in a PTY (CI runs with --ignored) +async fn zdr_team_locks_row_and_suppresses_banner() { + run_zdr().await.expect("zdr locked-row e2e"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore] // opt-in: spawns the real pager binary in a PTY (CI runs with --ignored) +async fn team_member_sees_admin_managed_row_and_no_banner() { + run_team_member().await.expect("team-member locked-row e2e"); +} + +/// Rollout flag forced on (the banner would show for a plain opted-out user), +/// the sandbox's fake `XAI_API_KEY` removed so the seeded team OAuth entry is +/// the active auth, and ZDR product access enabled — without it a ZDR account +/// gets the blocked welcome screen ("not yet available") and can never reach +/// settings; the row lock and banner suppression key off `is_zdr` regardless. +fn locked_row_env_ops() -> [EnvOp<'static>; 3] { + [ + EnvOp::set("GROK_PRIVACY_NOTICE_ROLLOUT", "1"), + EnvOp::set("GROK_ZDR_ACCESS_ENABLED", "1"), + EnvOp::remove("XAI_API_KEY"), + ] +} + +async fn run_zdr() -> Result<()> { + let content = ContentController::start() + .await + .context("start mock server")?; + seed_fake_oauth_zdr_team(&content, "pty-zdr-user"); + + let mut pager = launch(&content).context("launch pager")?; + assert_no_banner_on_welcome(&mut pager)?; + + let line = open_settings_and_grab_row_line(&mut pager)?; + assert!( + line.contains("ZDR"), + "ZDR lock must show `ZDR` on the {ROW_LABEL:?} row: {line:?}\nscreen:\n{}", + pager.screen_contents() + ); + assert!( + !line.contains("Opt"), + "ZDR lock must replace the Opt in/Opt out value: {line:?}\nscreen:\n{}", + pager.screen_contents() + ); + assert!( + !line.contains(CHEVRON), + "locked row must not render the `{CHEVRON}` enter affordance: {line:?}\nscreen:\n{}", + pager.screen_contents() + ); + // Wrong-variant guard: the team-managed lock reason must not appear. + assert!( + !pager.contains_text("Managed by your team admin"), + "ZDR account rendered the team-managed lock:\n{}", + pager.screen_contents() + ); + + // Expanded view: the lock reason REPLACES the registry description. + expand_focused_row(&mut pager, ZDR_REASON)?; + assert!( + !pager.contains_text(DESCRIPTION_PREFIX), + "locked expansion must replace the description, not append to it:\n{}", + pager.screen_contents() + ); + assert!( + !pager.contains_text("Managed by your team admin"), + "ZDR expansion rendered the team-managed reason:\n{}", + pager.screen_contents() + ); + Ok(()) +} + +async fn run_team_member() -> Result<()> { + let content = ContentController::start() + .await + .context("start mock server")?; + seed_fake_oauth_team_member(&content, "pty-team-user"); + + let mut pager = launch(&content).context("launch pager")?; + assert_no_banner_on_welcome(&mut pager)?; + + let line = open_settings_and_grab_row_line(&mut pager)?; + assert!( + line.contains("Opt out \u{00B7} Admin Managed"), + "team-managed lock must show `Opt out · Admin Managed`: {line:?}\nscreen:\n{}", + pager.screen_contents() + ); + assert!( + !line.contains("ZDR"), + "team-managed lock must not show the ZDR value: {line:?}\nscreen:\n{}", + pager.screen_contents() + ); + assert!( + !line.contains(CHEVRON), + "locked row must not render the `{CHEVRON}` enter affordance: {line:?}\nscreen:\n{}", + pager.screen_contents() + ); + + // Expanded view: the lock reason REPLACES the registry description. + expand_focused_row(&mut pager, TEAM_REASON)?; + assert!( + !pager.contains_text(DESCRIPTION_PREFIX), + "locked expansion must replace the description, not append to it:\n{}", + pager.screen_contents() + ); + // Wrong-variant guard: the ZDR reason must not appear. + assert!( + !pager.contains_text("Zero Data Retention"), + "team-managed expansion rendered the ZDR reason:\n{}", + pager.screen_contents() + ); + Ok(()) +} + +fn launch(content: &ContentController) -> Result { + let project = tempfile::tempdir().context("project dir")?; + std::fs::create_dir_all(project.path().join(".git")).context("create .git")?; + let binary = pager_binary().context("resolve pager binary")?; + let pager = spawn_pager(&binary, content, project.path()).context("spawn pager")?; + // Keep the project dir alive for the pager's lifetime. + std::mem::forget(project); + Ok(pager) +} + +fn spawn_pager(binary: &Path, content: &ContentController, project: &Path) -> Result { + PtyHarness::spawn_with_content_env_ops_in_dir( + binary, + ROWS, + COLS, + content, + &[], + &locked_row_env_ops(), + Some(project), + ) +} + +/// Sync on "New worktree" — rendered only on the authenticated welcome menu +/// ("Quit" also renders while auth is still pending, where the banner is +/// gated off regardless of team state) — then assert the banner never shows. +fn assert_no_banner_on_welcome(pager: &mut PtyHarness) -> Result<()> { + pager + .wait_for_text("New worktree", Duration::from_secs(20)) + .context("authenticated welcome screen")?; + pager.update(Duration::from_secs(2)); + assert!( + !pager.contains_text(BANNER_TITLE), + "team account must suppress the privacy banner:\n{}", + pager.screen_contents() + ); + Ok(()) +} + +/// Open settings via F2 and return the screen line holding the Coding data +/// sharing row. F2's `OpenSettings` binding is `When::AgentScreen` only — +/// the welcome screen never routes it — so Enter first starts a session +/// (`Action::NewSession`), then F2 in the agent view opens the modal +/// (`dispatch_open_settings`). +/// +/// Navigation is always via the modal's `/` filter: typing the query clamps +/// the selection to the filtered set (`clamp_selected_to_visible`) and Enter +/// commits back to Browse PRESERVING query and selection, so afterwards the +/// row is both in the viewport and FOCUSED — the precondition for `→` +/// expansion in [`expand_focused_row`]. The lowercase query cannot collide +/// with the case-sensitive label. +fn open_settings_and_grab_row_line(pager: &mut PtyHarness) -> Result { + pager.inject_keys(keys::ENTER).context("start session")?; + pager + .wait_for_text_absent("New worktree", Duration::from_secs(20)) + .context("agent view opened")?; + pager.update(Duration::from_millis(500)); + pager.inject_keys(keys::F2).context("press F2")?; + pager + .wait_for_text("Appearance", Duration::from_secs(20)) + .context("settings modal opened")?; + pager.inject_keys(b"/").context("focus filter")?; + pager.update(Duration::from_millis(300)); + pager + .inject_keys(b"coding data sharing") + .context("type filter query")?; + pager.update(Duration::from_millis(300)); + pager.inject_keys(keys::ENTER).context("commit filter")?; + pager + .wait_for_text(ROW_LABEL, Duration::from_secs(20)) + .context("Coding data sharing row visible")?; + pager.update(Duration::from_millis(500)); + let screen = pager.screen_contents(); + screen + .lines() + .find(|l| l.contains(ROW_LABEL)) + .map(str::to_owned) + .with_context(|| format!("{ROW_LABEL:?} line not found:\n{screen}")) +} + +/// Expand the focused row with `→` (Browse-mode `KeyCode::Right` inserts the +/// focused key into `expanded_keys`) and wait for `reason` to render. +/// Callers reach here from [`open_settings_and_grab_row_line`], which leaves +/// the Coding data sharing row focused. +fn expand_focused_row(pager: &mut PtyHarness, reason: &str) -> Result<()> { + pager.inject_keys(keys::RIGHT).context("expand row")?; + pager.update(Duration::from_millis(300)); + pager + .wait_for_text(reason, Duration::from_secs(20)) + .with_context(|| format!("expanded lock reason {reason:?} on screen")) +} diff --git a/crates/codegen/xai-grok-pager/Cargo.toml b/crates/codegen/xai-grok-pager/Cargo.toml index 3a65ecc..8ea39a7 100644 --- a/crates/codegen/xai-grok-pager/Cargo.toml +++ b/crates/codegen/xai-grok-pager/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xai-grok-pager" -version = "0.2.111" +version = "0.2.112" edition.workspace = true license = "Apache-2.0" authors = ["xAI"] diff --git a/crates/codegen/xai-grok-pager/docs/custom-hooks.md b/crates/codegen/xai-grok-pager/docs/custom-hooks.md index 142f75e..110f285 100644 --- a/crates/codegen/xai-grok-pager/docs/custom-hooks.md +++ b/crates/codegen/xai-grok-pager/docs/custom-hooks.md @@ -51,8 +51,11 @@ Hooks are discovered from several places (all are merged): | Global | `~/.claude/settings.json` | Always | Claude Code compatibility | | Project | `/.grok/hooks/*.json` | Requires trust | Per-repo automation | | Project | `/.claude/settings.json` | Requires trust | Claude compatibility | +| Config | `config.toml`, `managed_config.toml`, `requirements.toml` | Always | Hooks shipped in your (or your organization's) config | | Plugin | Bundled inside installed plugins | Per-plugin | Shared team hooks | +Config-file hooks use the same schema in TOML form; see the [Hooks user guide](user-guide/10-hooks.md#hooks-in-config-files) for details. + **Trusting a project**: Open the hooks modal (`Ctrl+L` on non–VS Code family, or `/hooks` on any terminal including VS Code family) or run `/hooks-trust` (the same folder-trust gate as `--trust`, recorded in `~/.grok/trusted_folders.toml`) the first time you open a project with hooks. This prevents untrusted repos from running arbitrary code. ## The Hook JSON Format diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/10-hooks.md b/crates/codegen/xai-grok-pager/docs/user-guide/10-hooks.md index 572fd28..5ad2ef0 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/10-hooks.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/10-hooks.md @@ -68,9 +68,12 @@ Hooks are discovered from several places (all are merged): | Project | `/.grok/hooks/*.json` | Requires trust | Per-repo automation | | Project | `/.claude/settings.json` (and `settings.local.json`) | Requires trust | Claude compatibility (configurable) | | Project | `/.cursor/hooks.json` | Requires trust | Cursor compatibility (configurable) | +| Config | `~/.grok/config.toml` | Always | Your hooks alongside the rest of your config | +| Config | `managed_config.toml` (`$GROK_HOME` and `/etc/grok`) | Always | Organization-distributed hooks (server-synced and on-device) | +| Config | `requirements.toml` (user and system) | Always | Organization-distributed hooks in the requirements layer | | Plugin | Bundled inside installed plugins | Per-plugin | Shared team hooks | -The Claude and Cursor hook sources are scanned by default. To disable scanning for a specific vendor, set `[compat.] hooks = false` in `~/.grok/config.toml` or the corresponding environment variable. See [Configuration](05-configuration.md#harness-compatibility) for details. +Config-file hooks live in the same TOML your organization already controls; see [Hooks in Config Files](#hooks-in-config-files) for the format. The compatible vendor hook sources are scanned by default. To disable scanning for a specific vendor, set `[compat.] hooks = false` in `~/.grok/config.toml` or the corresponding environment variable. See [Configuration](05-configuration.md#harness-compatibility) for details. **Trusting a project**: The first time you open a project with hooks, you must trust it before its project hooks will run -- until then they are silently skipped. Grant trust by running `/hooks-trust` (or launching with `--trust`); the decision is recorded in the unified folder-trust store (`~/.grok/trusted_folders.toml`), the same gate that governs repo-local MCP/LSP servers. Global hooks in `~/.grok/hooks/` are always trusted and need no entry. This prevents untrusted repos from running arbitrary code. @@ -168,6 +171,47 @@ A matcher keeps its original name too, so `Bash` matches both `Bash` and `run_te --- +## Hooks in Config Files + +Hooks can also live directly in your Grok config, so a team can distribute them with the rest of their configuration instead of shipping separate JSON files. The same `hooks` object is read from three TOML files: + +| File | Tier | Who sets it | +|------|------|-------------| +| `~/.grok/config.toml` | User | You | +| `managed_config.toml` (`$GROK_HOME`, `/etc/grok`) | Managed / system | Your organization | +| `requirements.toml` (user and system) | Requirements | Your organization | + +The TOML is structurally identical to the JSON hook object, so an existing hook transliterates directly: + +```toml +[[hooks.PreToolUse]] +matcher = "Bash|Write|Edit" +hooks = [ + { type = "command", command = "/opt/guard/pretooluse.sh", timeout = 10 }, +] +``` + +Each matcher group is a `[[hooks.]]` entry with an optional `matcher` and an inner `hooks` array of handlers. The handler fields (`type`, `command`, `url`, `timeout`, `env`) and event names are exactly the same as the [JSON format](#the-hook-json-format). + +TOML offers two equivalent notations for the inner handlers, and both parse to the identical structure. The inline-table array shown above is recommended: it reads best for the common single-handler case. The nested array-of-tables form is also accepted: + +```toml +[[hooks.PreToolUse]] +matcher = "Bash|Write|Edit" +[[hooks.PreToolUse.hooks]] +type = "command" +command = "/opt/guard/pretooluse.sh" +timeout = 10 +``` + +Prefer the inline form to avoid repeating the `[[hooks..hooks]]` header for each handler. + +- **Additive across layers.** Every layer's hooks run; a lower-priority layer adds hooks but never replaces another layer's block. A hook defined identically in more than one layer is deduplicated, keeping the highest-authority copy. +- **Provenance labels.** Config hooks appear in `/hooks` tagged by origin (`managed:`, `requirements/user:`, `user:`, and so on) so you can see which layer contributed each one. +- **No read-time expansion.** A literal `${VAR}` in a `command` or `url` reaches the hook runner unchanged, matching JSON hook-file semantics; the runner performs the single expansion. + +--- + ## Writing Hook Scripts ### Input diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/20-background-tasks.md b/crates/codegen/xai-grok-pager/docs/user-guide/20-background-tasks.md index 5b24f97..b8c837a 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/20-background-tasks.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/20-background-tasks.md @@ -196,6 +196,14 @@ Whenever background work is still running while the agent looks idle — between It counts running background commands, monitors, scheduled `/loop` tasks, and background subagents, and updates live as each finishes. Any of them can wake the agent for a new turn (commands and subagents on completion, monitors on events, loops on their timer), so the cue stays up until nothing is left. The running counts live only on this status line: completions land in the transcript as a single "Task completed" chip, and "Worked for" markers stay plain — the transcript never repeats or restates the running counts. +While a turn is waiting on background work (blocked in a `get_task_output` or `wait_tasks` call), the status line adds a hint that typing takes over immediately: + +``` +◎ 1 command still running · send a message to interrupt +``` + +The same hint appears as `◎ waiting · send a message to interrupt` when the agent is waiting on something with no live counter (a sleep, or work that already finished). Sending a message interrupts the wait and runs your message right away. The transcript keeps its usual shape throughout: one "Worked for" marker when the turn ends. When a completion wakes the agent and it replies, that reply gets its own "Worked for" marker; a wake the agent answers silently leaves no trace in the transcript — unless it fails, in which case a "Turn failed" line appears even for a silent wake, so a standing instruction never stops executing invisibly. + --- ## Use Cases and Patterns diff --git a/crates/codegen/xai-grok-pager/src/acp/mod.rs b/crates/codegen/xai-grok-pager/src/acp/mod.rs index 38df40c..04999cf 100644 --- a/crates/codegen/xai-grok-pager/src/acp/mod.rs +++ b/crates/codegen/xai-grok-pager/src/acp/mod.rs @@ -60,6 +60,9 @@ pub struct AcpConnection { pub auth_methods: Vec, /// Cancellation token to stop the agent. pub cancel: CancellationToken, + /// In-process agent worker thread (`connect` only). Join after cancel so + /// session actors can flush SessionEnd hooks. `None` in leader mode. + pub agent_thread: Option>>, /// ACP-advertised slash commands parsed from `InitializeResponse.meta.availableCommands`. /// Seeded into every new `AgentSession` so autocomplete has shell builtins /// and skills immediately, before any `AvailableCommandsUpdate` arrives. @@ -227,6 +230,7 @@ pub async fn connect(cancel: &CancellationToken, flags: ConnectFlags) -> Result< is_grok_shell, auth_methods, cancel: spawned.cancel, + agent_thread: Some(spawned.thread_handle), available_commands, needs_login, login_label, @@ -353,6 +357,7 @@ pub async fn connect_via_leader( is_grok_shell, auth_methods, cancel: bridge.cancel, + agent_thread: None, available_commands, needs_login, login_label, diff --git a/crates/codegen/xai-grok-pager/src/acp/spawn.rs b/crates/codegen/xai-grok-pager/src/acp/spawn.rs index dedd8d0..ead8e86 100644 --- a/crates/codegen/xai-grok-pager/src/acp/spawn.rs +++ b/crates/codegen/xai-grok-pager/src/acp/spawn.rs @@ -3,8 +3,10 @@ //! Simplified to only support GrokShell (in-process) mode. //! Subprocess and remote modes can be added later if needed. +use std::io::IsTerminal; use std::rc::Rc; use std::thread; +use std::time::Duration; use anyhow::Result; use tokio_util::sync::CancellationToken; @@ -14,15 +16,28 @@ use xai_acp_lib::{ acp_channels, }; use xai_grok_shell::{ - agent::{MvpAgent, config::Config as AgentConfig, models::RefreshStrategy}, + agent::{ + MvpAgent, activity::SESSION_FLUSH_GRACE, config::Config as AgentConfig, + models::RefreshStrategy, + }, auth::AuthManager, util::grok_home::grok_home, }; +/// Extra slack when joining the agent OS thread after cancel so the flush +/// can finish and the thread can unwind. +const AGENT_JOIN_SLACK: Duration = Duration::from_secs(2); + +/// How long the join stays silent before telling an interactive user why exit +/// is taking a moment. Short joins (the common case) print nothing. +const JOIN_NOTICE_AFTER: Duration = Duration::from_millis(1500); + /// Result of spawning a child agent. pub struct SpawnedAgent { - /// Kept alive so the thread isn't detached. Will be used for graceful shutdown. - pub _thread_handle: thread::JoinHandle>, + /// Agent worker OS thread. Hand to [`AgentShutdownGuard`] so the worker is + /// cancelled and joined — letting session actors flush SessionEnd hooks — + /// on every exit path. + pub thread_handle: thread::JoinHandle>, pub channel: AcpClientChannel, pub cancel: CancellationToken, /// The agent's `AuthManager`, shared so pager-side consumers (e.g. the voice @@ -30,6 +45,128 @@ pub struct SpawnedAgent { pub auth_manager: std::sync::Arc, } +/// The single teardown mechanism for an in-process agent: cancels the worker +/// and joins it on drop, so session actors always get +/// `SessionCommand::Shutdown` (SessionEnd hooks, memory save) before the +/// process exits — on normal return, `?` bail, or panic unwind alike. +/// +/// Hold one from every site that calls [`spawn_grok_shell`] (headless, the TUI, +/// `models`, `worktree`, `share`). Scope-end drop is the default; the TUI is the +/// one caller that drops it explicitly, because the join has to happen before +/// background processes are reaped (see `app::run`). +pub struct AgentShutdownGuard { + cancel: CancellationToken, + thread: Option>>, +} + +impl AgentShutdownGuard { + /// Guard an in-process agent worker. A `None` thread makes the guard a + /// no-op cancel (leader mode has no in-process worker to join). + pub fn new(cancel: CancellationToken, thread: Option>>) -> Self { + Self { cancel, thread } + } +} + +impl Drop for AgentShutdownGuard { + fn drop(&mut self) { + self.cancel.cancel(); + let Some(handle) = self.thread.take() else { + return; + }; + let timeout = SESSION_FLUSH_GRACE + AGENT_JOIN_SLACK; + match join_agent_thread(handle, timeout) { + JoinOutcome::Joined => {} + JoinOutcome::Failed(error) => { + tracing::warn!(%error, "agent worker exited with error after cancel"); + } + JoinOutcome::Panicked(panic) => { + tracing::warn!(%panic, "agent worker panicked after cancel"); + } + JoinOutcome::TimedOut => { + tracing::warn!( + timeout_ms = timeout.as_millis() as u64, + "agent worker did not exit within grace after cancel; \ + session hooks may be incomplete" + ); + } + JoinOutcome::HelperLost => { + tracing::warn!("agent worker join helper disappeared; proceeding"); + } + } + } +} + +/// Why the join ended, so each case is explicit at the call site (and callers +/// can tell a completed flush from an abandoned one). +#[derive(Debug, PartialEq, Eq)] +enum JoinOutcome { + /// Worker returned cleanly: session actors flushed within the grace. + Joined, + /// Worker returned an error; the flush may be incomplete. + Failed(String), + /// Worker panicked, with the payload rendered as text. + Panicked(String), + /// Worker was still running when the budget elapsed. + TimedOut, + /// The join helper vanished without reporting (helper thread itself died). + HelperLost, +} + +/// Wait up to `timeout` for a cancelled agent worker to exit. +/// +/// The blocking `join` runs on a helper thread so this stays callable from +/// `Drop` — which cannot await — while every caller sits on the async runtime. +/// On timeout that helper is abandoned rather than joined; this is safe **only +/// because every caller is on its way out of the process**, so the OS reaps the +/// thread at exit. Do not reuse this outside teardown. +fn join_agent_thread(handle: thread::JoinHandle>, timeout: Duration) -> JoinOutcome { + use std::sync::mpsc::RecvTimeoutError; + + let (tx, rx) = std::sync::mpsc::channel(); + thread::spawn(move || { + let _ = tx.send(handle.join()); + }); + + // Two-phase wait: silent for a short join (overwhelmingly the common case), + // then a one-line notice so a slow SessionEnd hook does not look like a + // frozen exit. Only for a terminal — piped/JSON consumers stay clean. + let quiet = timeout.min(JOIN_NOTICE_AFTER); + match rx.recv_timeout(quiet) { + Ok(result) => return classify_join(result), + Err(RecvTimeoutError::Timeout) => { + if std::io::stderr().is_terminal() { + eprintln!("Finishing session hooks…"); + } + } + Err(RecvTimeoutError::Disconnected) => return JoinOutcome::HelperLost, + } + match rx.recv_timeout(timeout.saturating_sub(quiet)) { + Ok(result) => classify_join(result), + Err(RecvTimeoutError::Timeout) => JoinOutcome::TimedOut, + Err(RecvTimeoutError::Disconnected) => JoinOutcome::HelperLost, + } +} + +fn classify_join(result: thread::Result>) -> JoinOutcome { + match result { + Ok(Ok(())) => JoinOutcome::Joined, + Ok(Err(e)) => JoinOutcome::Failed(e.to_string()), + Err(payload) => JoinOutcome::Panicked(panic_message(payload)), + } +} + +/// Render a panic payload as text — `panic!` payloads are `&str` or `String`, +/// so the log shows the message instead of an opaque `Any`. +fn panic_message(payload: Box) -> String { + if let Some(s) = payload.downcast_ref::<&'static str>() { + (*s).to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "non-string panic payload".to_string() + } +} + /// Spawn a GrokShell agent in a background thread. /// /// Returns the ACP client channel for communication and a cancellation token. @@ -91,7 +228,7 @@ pub async fn spawn_grok_shell( spawn_agent_thread_direct(spawn_fn, acp_agent, agent_cancel.clone(), skills_paths)?; Ok(SpawnedAgent { - _thread_handle: handle, + thread_handle: handle, channel: acp_client, cancel: agent_cancel, auth_manager: auth_manager_for_pager, @@ -161,9 +298,74 @@ fn spawn_agent_thread_direct( }; tokio::task::yield_now().await; - // Keep running until cancelled + // Keep running until cancelled, then flush every live session + // actor (SessionEnd hooks + memory save) before the LocalSet / + // agent drop. Session actors live on dedicated OS threads and + // only exit cleanly on SessionCommand::Shutdown; without this + // flush, /exit and headless quit race process death and skip + // SessionEnd. Mirrors leader auto-update / relaunch. cancel.cancelled().await; + agent_rc.flush_all_sessions(SESSION_FLUSH_GRACE).await; anyhow::Result::Ok(()) }) })?) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn join_reports_clean_worker_exit() { + let handle = thread::spawn(|| Ok(())); + assert_eq!( + join_agent_thread(handle, Duration::from_secs(5)), + JoinOutcome::Joined + ); + } + + #[test] + fn join_reports_worker_error() { + let handle = thread::spawn(|| Err(anyhow::anyhow!("flush failed"))); + assert_eq!( + join_agent_thread(handle, Duration::from_secs(5)), + JoinOutcome::Failed("flush failed".to_string()) + ); + } + + /// The timeout branch the built-binary e2e cannot reach: a wedged worker + /// (e.g. a hung SessionEnd hook) is abandoned once the budget elapses + /// instead of holding the process open indefinitely. + #[test] + fn join_abandons_wedged_worker_at_budget() { + let handle = thread::spawn(|| { + thread::sleep(Duration::from_secs(30)); + Ok(()) + }); + let started = std::time::Instant::now(); + assert_eq!( + join_agent_thread(handle, Duration::from_millis(50)), + JoinOutcome::TimedOut + ); + assert!( + started.elapsed() < Duration::from_secs(5), + "join must return at its budget, not wait out the worker" + ); + } + + #[test] + fn panic_payloads_render_as_text() { + assert_eq!( + classify_join(Err(Box::new("boom"))), + JoinOutcome::Panicked("boom".to_string()) + ); + assert_eq!( + classify_join(Err(Box::new("boom".to_string()))), + JoinOutcome::Panicked("boom".to_string()) + ); + assert_eq!( + classify_join(Err(Box::new(7u32))), + JoinOutcome::Panicked("non-string panic payload".to_string()) + ); + } +} diff --git a/crates/codegen/xai-grok-pager/src/acp/tracker.rs b/crates/codegen/xai-grok-pager/src/acp/tracker.rs index a2b1ddc..0df9be1 100644 --- a/crates/codegen/xai-grok-pager/src/acp/tracker.rs +++ b/crates/codegen/xai-grok-pager/src/acp/tracker.rs @@ -268,6 +268,7 @@ pub struct AcpUpdateTracker { last_stream_start_ms: Option, /// Monotonic count of live parent-agent updates that changed scrollback. agent_output_epoch: u64, + epoch_at_last_finish: u64, /// Session project cwd for display-only redundant-`cd` stripping. /// Set from [`AgentSession::cwd`]; not used for execution. session_cwd: Option, @@ -376,9 +377,15 @@ impl AcpUpdateTracker { pub fn new() -> Self { Self::default() } - /// Current boundary for visible live parent-agent output. - pub(crate) fn agent_output_epoch(&self) -> u64 { - self.agent_output_epoch + pub(crate) fn output_since_last_finish(&self) -> bool { + self.agent_output_epoch != self.epoch_at_last_finish + } + /// Mark all output so far as accounted for without finishing the turn — + /// for terminals that must be skipped while a client command owns the + /// screen (a full `finish_turn` would flush mid-command state such as + /// `pending_compaction`). + pub(crate) fn snapshot_output_epoch(&mut self) { + self.epoch_at_last_finish = self.agent_output_epoch; } fn bump_agent_output_epoch(&mut self) { self.agent_output_epoch = self.agent_output_epoch.wrapping_add(1); @@ -827,6 +834,7 @@ impl AcpUpdateTracker { } /// Called when PromptResponse is received (turn complete). pub fn finish_turn(&mut self, scrollback: &mut ScrollbackState) { + self.epoch_at_last_finish = self.agent_output_epoch; self.finish_thinking(scrollback); if let Some(agent_id) = self.current_agent_msg.take() { scrollback.finish_running(agent_id); @@ -2782,31 +2790,51 @@ mod tests { let mut sb = ScrollbackState::new(); let mut tracker = AcpUpdateTracker::new(); assert!(tracker.handle_update(user_message("prompt"), &meta(), &mut sb)); - assert_eq!(tracker.agent_output_epoch(), 0); + assert_eq!(tracker.agent_output_epoch, 0); assert!(tracker.handle_update(agent_chunk("response"), &meta(), &mut sb)); - assert_eq!(tracker.agent_output_epoch(), 1); + assert_eq!(tracker.agent_output_epoch, 1); let replay = NotificationMeta { is_replay: true, ..Default::default() }; assert!(tracker.handle_update(agent_chunk(" replay"), &replay, &mut sb)); - assert_eq!(tracker.agent_output_epoch(), 1); + assert_eq!(tracker.agent_output_epoch, 1); assert!(tracker.handle_update(thought_chunk("thinking"), &meta(), &mut sb)); - assert_eq!(tracker.agent_output_epoch(), 2); + assert_eq!(tracker.agent_output_epoch, 2); assert!(tracker.handle_update( tool_call("read-1", acp::ToolKind::Read, "read_file"), &meta(), &mut sb, )); - assert_eq!(tracker.agent_output_epoch(), 3); + assert_eq!(tracker.agent_output_epoch, 3); assert!(tracker.handle_update(tool_update_completed("read-1"), &meta(), &mut sb)); - assert_eq!(tracker.agent_output_epoch(), 4); + assert_eq!(tracker.agent_output_epoch, 4); assert!(!tracker.handle_update( tool_call("todo-1", acp::ToolKind::Other, "TodoWrite"), &meta(), &mut sb, )); - assert_eq!(tracker.agent_output_epoch(), 4); + assert_eq!(tracker.agent_output_epoch, 4); + } + #[test] + fn output_since_last_finish_flips_per_turn() { + let mut sb = ScrollbackState::new(); + let mut tracker = AcpUpdateTracker::new(); + tracker.finish_turn(&mut sb); + assert!( + !tracker.output_since_last_finish(), + "no output right after a finish" + ); + assert!(tracker.handle_update(agent_chunk("wake reply"), &meta(), &mut sb)); + assert!( + tracker.output_since_last_finish(), + "an agent message chunk flips the flag" + ); + tracker.finish_turn(&mut sb); + assert!( + !tracker.output_since_last_finish(), + "the next finish snapshots the epoch again" + ); } #[test] fn streaming_thinking() { @@ -5858,7 +5886,7 @@ mod tests { ); assert_eq!(sb.len(), 1); assert_eq!(tracker.pending_tools.len(), 1); - let output_epoch = tracker.agent_output_epoch(); + let output_epoch = tracker.agent_output_epoch; let modified = tracker.handle_update( tool_update_in_progress_bg("tc1", b"started"), &meta(), @@ -5869,9 +5897,8 @@ mod tests { "bg tool deferral should suppress further output streaming" ); assert_eq!( - tracker.agent_output_epoch(), - output_epoch, - "deferral must not bump the epoch (re-pushes the parked marker)" + tracker.agent_output_epoch, output_epoch, + "deferral must not bump the epoch (it is not visible agent output)" ); assert_eq!(sb.len(), 1, "real execute entry kept for demotion"); assert!( @@ -5889,7 +5916,7 @@ mod tests { ); } /// Regression: a bg-tool deferral (here dropping the placeholder row) must - /// not bump `agent_output_epoch` — bumping re-pushed the parked marker. + /// not bump `agent_output_epoch` — it is not visible agent output. #[test] fn bg_tool_deferral_does_not_bump_agent_output_epoch() { let mut sb = ScrollbackState::new(); @@ -5900,7 +5927,7 @@ mod tests { &mut sb, ); assert_eq!(sb.len(), 1); - let epoch = tracker.agent_output_epoch(); + let epoch = tracker.agent_output_epoch; let update = acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from("tc1")), acp::ToolCallUpdateFields::new() @@ -5914,9 +5941,8 @@ mod tests { assert_eq!(sb.len(), 0, "placeholder dropped on deferral"); assert!(tracker.bg_deferred_tools.contains_key("tc1")); assert_eq!( - tracker.agent_output_epoch(), - epoch, - "deferral must not bump the epoch (re-pushes the parked marker)" + tracker.agent_output_epoch, epoch, + "deferral must not bump the epoch (it is not visible agent output)" ); } /// Eager kind=Other title=`run_terminal_command` must not flash in the TUI. diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/background.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/background.rs index f03b4cf..ba0bc84 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/background.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/background.rs @@ -229,15 +229,6 @@ pub(super) fn handle_task_backgrounded(notif: &acp::ExtNotification, app: &mut A bg.scrollback_entry_id = Some(entry_id); } - // Ext notifications reorder vs session updates: work registering after - // its awaiting wait must re-evaluate the skipped park. Root only — child - // tasks never enter root `bg_tasks`. - if !matches!(matched, SessionMatch::Child(_)) - && let Some((_, _, agent)) = resolve_notif_agent(app, &session_notif.session_id) - { - agent.maybe_push_parked_marker(); - } - is_active } @@ -608,9 +599,8 @@ pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppV // Prefer the human description for "Task completed/failed: …" labels // (same as "Task started"), falling back to the raw command only when // no description was supplied. - let (command, elapsed, mut description, scrollback_entry_id, was_running) = + let (command, elapsed, mut description, scrollback_entry_id) = if let Some(bg_task) = session.bg_tasks.get_mut(task_id) { - let was_running = bg_task.status == BgTaskStatus::Running; bg_task.status = if success { BgTaskStatus::Done } else { @@ -626,7 +616,6 @@ pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppV bg_task.elapsed(), bg_task.description.clone(), bg_task.scrollback_entry_id, - was_running, ) } else { // Task we didn't know about — use snapshot data. Prefer @@ -652,9 +641,7 @@ pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppV Some(d) } }); - // Unknown task: it never counted toward the parked marker's - // running total, so its completion is not a countdown edge. - (command, elapsed, description, None, false) + (command, elapsed, description, None) }; // Finish the "Task started" scrollback entry (stops bullet animation). @@ -707,14 +694,5 @@ pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppV }; scrollback.push_block(block); - // Re-eval a withheld park; the slot self-dedupes. Root sessions only. - // (Re-borrow: `resolve_target_view` consumed the earlier `&mut`.) - if was_running - && !matches!(matched, SessionMatch::Child(_)) - && let Some(agent) = app.agents.get_mut(&matched.agent_id()) - { - agent.maybe_push_parked_marker(); - } - is_active } diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/mod.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/mod.rs index ba731ba..22cbc94 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/mod.rs @@ -62,7 +62,8 @@ use routing::{ use prompt_origin::{finish_wake_turn, viewer_turn_anchor}; pub(crate) use prompt_origin::{ - is_server_initiated_prompt, is_wake_prompt, should_adopt_running_prompt, + is_scheduler_fired_prompt, is_server_initiated_prompt, is_wake_prompt, + should_adopt_running_prompt, }; pub(crate) use subagent_activity::finalize_killed_subagent; @@ -145,13 +146,9 @@ pub(crate) fn handle(msg: AcpClientMessage, app: &mut AppView) -> bool { AcpClientMessage::SessionNotification(notif) => { let mut meta = NotificationMeta::from_json(notif.request.meta.as_ref()); - // Wait-state bookkeeping after the agent borrow ends (parked marker). - let mut wait_state_agent: Option = None; - let affected = match find_session_match(app, ¬if.request.session_id) { Some(SessionMatch::Root(id)) => { let is_active = is_matched_agent_active(app, id); - wait_state_agent = Some(id); // Read before the agent borrow below. let stashed_adoption_pid = app .pending_running_adoptions @@ -250,6 +247,7 @@ pub(crate) fn handle(msg: AcpClientMessage, app: &mut AppView) -> bool { } if let Some(ts) = meta.turn_start_ms { agent.turn_start_ms = Some(ts); + agent.turn_start_ms_prompt = meta.prompt_id.clone(); } } @@ -565,12 +563,6 @@ pub(crate) fn handle(msg: AcpClientMessage, app: &mut AppView) -> bool { false } }; - if let Some(aid) = wait_state_agent { - // Parked marker (any tab — the update that created the wait state stamps the park time). - if let Some(agent) = app.agents.get_mut(&aid) { - agent.maybe_push_parked_marker(); - } - } notif.response_tx.send(Ok(())).ok(); affected } @@ -748,11 +740,6 @@ fn handle_interjection(notif: &acp::ExtNotification, app: &mut AppView) -> bool agent .scrollback .push_block(RenderBlock::interjection_prompt(text)); - // Interjecting into a parked wait continues the turn below this block — - // the withheld "Worked for …" marker must not fire late beneath it - // (shared-queue interjects render only via this broadcast, and the shell - // emits the queue-emptying `x.ai/queue/changed` right after it). - agent.suppress_parked_marker_on_interject(); is_active } diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/permissions.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/permissions.rs index 2ab2b9f..9993e92 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/permissions.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/permissions.rs @@ -303,11 +303,30 @@ fn build_permission_display( } }; - let description = mcp_args_lines(req); + let description = permission_description_lines(req); let bash_cmd = if is_execute { raw_command } else { None }; (title, description, bash_cmd) } +/// Lines shown under the permission title: protected-edit note (if any), then +/// MCP planned-argument lines (empty for bash/edit). +fn permission_description_lines(req: &acp::RequestPermissionRequest) -> Vec { + let mut lines = mcp_args_lines(req); + if is_edit_permission(req) + && let Some(desc) = protected_edit_description(req) + { + lines.insert(0, desc); + } + lines +} + +fn protected_edit_description(req: &acp::RequestPermissionRequest) -> Option { + let meta = req.meta.as_ref()?; + let protected: xai_grok_workspace::permission::ProtectedEditPermission = + serde_json::from_value(serde_json::Value::Object(meta.clone())).ok()?; + protected.description.filter(|s| !s.is_empty()) +} + /// Maximum stored lines for the MCP planned-arguments display. The overlay /// clips further (options always stay visible); this only bounds memory for /// pathologically large inputs. diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/prompt_origin.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/prompt_origin.rs index 8e64065..6e7fa94 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/prompt_origin.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/prompt_origin.rs @@ -25,7 +25,8 @@ pub(crate) fn is_scheduler_fired_prompt(prompt_id: &str) -> bool { /// `subagent-completed-…`, `notifications-…`). These run non-adopted — no /// `PromptResponse`, no viewer finalize — so their durable `TurnCompleted` is /// the only signal marking the back-to-idle point (see [`finish_wake_turn`]; -/// wake turns close markerless). Deliberately narrower than "non-adopted +/// a chatty wake closes with a marker, a silent one stays markerless). +/// Deliberately narrower than "non-adopted /// synthetic": goal turns render through the goal chip/loop chrome and /// `plan-resume-…` keeps its own markerless shape. pub(crate) fn is_wake_prompt(prompt_id: &str) -> bool { @@ -86,10 +87,64 @@ pub(super) fn viewer_turn_anchor(turn_start_ms: Option) -> std::time::Insta .unwrap_or(now) } -/// Close out a wake turn: markerless, but the stream must be finished here — -/// wake turns skip `PromptResponse`, so this is the only flush site for an -/// in-flight streamed entry (dead wakes included). Leaves a real turn's -/// stop-hook stash pending for its own marker rail. -pub(super) fn finish_wake_turn(agent: &mut AgentView) { +/// Close out a wake turn — the only flush site for its in-flight streamed +/// entries (wake turns skip `PromptResponse`). Markers: visible output closes +/// with one; silence closes with none — except failures, which surface even +/// when silent (the user's standing instruction stopped executing invisibly). +/// Silent rate limits defer to the retry notifications, like the real-turn +/// rails. +pub(super) fn finish_wake_turn( + agent: &mut AgentView, + prompt_id: &str, + stop_reason: &str, + agent_result: Option<&str>, +) { + use crate::scrollback::blocks::SessionEvent; + + let had_output = agent.session.tracker.output_since_last_finish(); agent.session.tracker.finish_turn(&mut agent.scrollback); + // The stored `turn_start_ms` may belong to an earlier turn (a silent wake + // streamed no deltas of its own; interleaved deltas can re-stamp it) — + // claim an elapsed only when the anchor is provably this wake's. + let anchor_is_ours = agent.turn_start_ms_prompt.as_deref() == Some(prompt_id); + let elapsed = if had_output && anchor_is_ours { + agent.turn_start_ms.and_then(|start_ms| { + let ms = chrono::Utc::now() + .timestamp_millis() + .saturating_sub(start_ms); + (ms >= 0).then(|| std::time::Duration::from_millis(ms as u64)) + }) + } else { + None + }; + let already_failed = agent.failed_wake_marker_for.as_deref() == Some(prompt_id); + let event = match stop_reason { + "error" | "rate_limit" + if already_failed || (stop_reason == "rate_limit" && !had_output) => + { + None + } + "error" | "rate_limit" => { + agent.failed_wake_marker_for = Some(prompt_id.to_string()); + Some(SessionEvent::TurnFailed { + error: agent_result.map(str::to_string).unwrap_or_else(|| { + if stop_reason == "error" { + "unknown error".to_string() + } else { + "rate limited".to_string() + } + }), + elapsed, + }) + } + "cancelled" if !had_output => None, + "cancelled" => Some(SessionEvent::TurnCancelled { + elapsed: elapsed.unwrap_or_default(), + }), + _ if !had_output => None, + _ => Some(SessionEvent::TurnCompleted { elapsed }), + }; + if event.is_some() { + crate::app::turn_completion::push_turn_terminal_marker(agent, event, Some(prompt_id)); + } } diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/queue.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/queue.rs index 1c74dab..d37c3e6 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/queue.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/queue.rs @@ -220,11 +220,6 @@ pub(super) fn handle_queue_changed(notif: &acp::ExtNotification, app: &mut AppVi new_text: None, }); } - // A queue change can empty the visible queue mid-wait — the marker - // may become eligible now (see `maybe_push_parked_marker`). - if let Some(agent) = app.agents.get_mut(&aid) { - agent.maybe_push_parked_marker(); - } } // Adoption / turn-start correlation. diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/session_notification.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/session_notification.rs index 16ed0ef..abfefae 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/session_notification.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/session_notification.rs @@ -223,9 +223,40 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu false } else if is_wake_prompt(&prompt_id) { if agent.session.state.is_busy() { + if agent.session.state.command_in_flight().is_some() { + agent.session.tracker.snapshot_output_epoch(); + } + let errored = matches!(stop_reason.as_str(), "error" | "rate_limit"); + if errored && agent.failed_wake_marker_for.as_deref() != Some(&*prompt_id) { + agent.failed_wake_marker_for = Some(prompt_id.clone()); + agent.push_end_marker_block( + crate::scrollback::blocks::SessionEvent::TurnFailed { + error: agent_result + .clone() + .unwrap_or_else(|| "unknown error".to_string()), + elapsed: None, + }, + Vec::new(), + Some(prompt_id.clone()), + ); + true + } else { + false + } + } else { + finish_wake_turn(agent, &prompt_id, &stop_reason, agent_result.as_deref()); + true + } + } else if is_server_initiated_prompt(&prompt_id) + && !is_scheduler_fired_prompt(&prompt_id) + { + if agent.session.state.is_busy() { + if agent.session.state.command_in_flight().is_some() { + agent.session.tracker.snapshot_output_epoch(); + } false } else { - finish_wake_turn(agent); + agent.session.tracker.finish_turn(&mut agent.scrollback); true } } else { @@ -455,7 +486,6 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu info.scrollback_entry_id = Some(entry_id); info.is_background = is_background; } - agent.maybe_push_parked_marker(); } else if let Some(info) = agent.subagent_sessions.get_mut(&child_session_id) { info.is_background = is_background; } @@ -596,9 +626,6 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu crate::app::subagent::finalize_finished_child_view(child_view, elapsed_dur); } } - if !resuming { - agent.maybe_push_parked_marker(); - } true } XaiSessionUpdate::HookAnnotation { message } => { diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/interjection.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/interjection.rs index 53d1cb2..191b24e 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/interjection.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/interjection.rs @@ -1,53 +1,9 @@ #![cfg_attr(rustfmt, rustfmt::skip)] use super::*; - /// Regression: a shared-queue interjection renders only via the broadcast, - /// and the shell emits the queue-emptying `x.ai/queue/changed` right after - /// it — which used to fire the withheld parked marker BELOW the just- - /// rendered user message ("Worked for …" under the follow-up, flipped - /// transcript order). The broadcast must consume the marker slot instead. #[test] - fn interjection_broadcast_mid_park_forgoes_parked_marker() { - use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait}; - - let mut app = make_app_with_agent("sess-park"); - { - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - agent.session.state = AgentState::TurnRunning; - agent.session.current_prompt_id = Some("p1".into()); - simulate_task_output_wait(agent, "bg-1"); - assert!(agent.is_parked_on_sendable_wait()); - } - - assert!(handle_ext_notification( - &interjection_broadcast("sess-park", "queued follow-up"), - &mut app, - )); - - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - assert_eq!( - agent.parked_wait_marker_for, - Some(crate::app::agent_view::ParkedMarkerSlot::Forgone( - "p1".into() - )), - "broadcast render must consume the parked-marker slot as Forgone" - ); - // The queue-changed following the broadcast must not fire it late. - agent.maybe_push_parked_marker(); - assert_eq!( - count_parked(agent), - 0, - "no late 'Worked for …' marker under the interjection" - ); - } - - /// Regression: a Forgone slot (interjection continued - /// the parked turn, no marker on screen) must silence later marker pushes - /// — a full "Worked for …" line under the interjected message would - /// recreate the flipped transcript. - #[test] - fn forgone_slot_suppresses_later_marker_pushes() { - use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait}; + fn interjection_broadcast_mid_park_adds_no_marker() { + use crate::app::agent_view::test_fixtures::{count_turn_markers, simulate_task_output_wait}; let mut app = make_app_with_agent("sess-park"); { @@ -57,32 +13,44 @@ insert_running_task(agent, "t10", "sleep 10"); insert_running_task(agent, "t15", "sleep 15"); simulate_task_output_wait(agent, "t15"); - // The parked drain interjected a queued row before the marker - // became eligible: slot consumed WITHOUT a marker. - agent.suppress_parked_marker_on_interject(); - assert!(agent.renders_parked(), "forgone slot keeps parked chrome"); - assert_eq!(count_parked(agent), 0, "no marker on screen"); + assert!(agent.is_parked_on_sendable_wait()); + assert_eq!(count_turn_markers(agent), 0, "the park writes no row"); + } + + assert!(handle_ext_notification( + &interjection_broadcast("sess-park", "queued follow-up"), + &mut app, + )); + + { + let agent = app.agents.get_mut(&AgentId(0)).unwrap(); + assert_eq!( + last_interjection_text(&agent.scrollback).as_deref(), + Some("queued follow-up"), + ); + assert_eq!( + count_turn_markers(agent), + 0, + "no 'Worked for …' marker around the interjection" + ); } - // A task completing in the still-parked window must stay silent. handle_ext_notification( &make_task_completed_notif("sess-park", "t10", "sleep 10", Some(0)), &mut app, ); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); assert_eq!( - count_parked(agent), + count_turn_markers(agent), 0, "no 'Worked for …' tick under the interjection" ); + assert!(agent.renders_parked(), "the parked chrome stays on"); } - /// "sleep 10, 15, 20 in the background": completions within one park - /// episode push chips only — the marker never re-pushes. (Elapsed - /// renders as "0.0s": `turn_started_at` is unset in this fixture.) #[test] - fn parked_completions_push_chips_without_marker_repush() { - use crate::app::agent_view::test_fixtures::simulate_task_output_wait; + fn parked_completions_push_chips_without_markers() { + use crate::app::agent_view::test_fixtures::{count_turn_markers, simulate_task_output_wait}; let mut app = make_app_with_agent("sess-park"); { @@ -93,12 +61,9 @@ insert_running_task(agent, "t15", "sleep 15"); insert_running_task(agent, "t20", "sleep 20"); simulate_task_output_wait(agent, "t20"); - agent.maybe_push_parked_marker(); assert!(agent.renders_parked()); } - // Each completion lands as a chip; no marker re-push, no "N commands - // still running." lines. handle_ext_notification( &make_task_completed_notif("sess-park", "t10", "sleep 10", Some(0)), &mut app, @@ -119,9 +84,9 @@ let agent = app.agents.get_mut(&AgentId(0)).unwrap(); assert_eq!( - parked_marker_messages(agent), - vec!["Worked for 0.0s".to_string()], - "one plain marker per park episode — completions never re-push" + count_turn_markers(agent), + 0, + "completions during a park never write a marker" ); assert!( work_status_lines(&agent.scrollback).is_empty(), @@ -129,58 +94,16 @@ ); } - /// Parity with the bg-command completion rail: a park withheld at park - /// time (held queue) gets re-evaluated by a subagent completion once the - /// blocker cleared, so the boundary marker isn't deferred to whenever the - /// next unrelated notification happens to arrive. #[test] - fn subagent_finish_reevaluates_withheld_parked_marker() { - use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all}; + fn consecutive_subagent_finishes_stay_markerless() { + use crate::app::agent_view::test_fixtures::count_turn_markers; let mut app = make_app_with_agent("sess-park"); { let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - agent.session.state = AgentState::TurnRunning; - agent.session.current_prompt_id = Some("p1".into()); - for child_id in ["child-1", "child-2"] { - agent - .subagent_sessions - .insert(child_id.into(), make_subagent_info(child_id)); - } - simulate_wait_all(agent); - // Held queue at park time: the marker is withheld. - agent.session.enqueue_prompt("queued follow-up".into()); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 0, "held queue withholds the marker"); - // The queue drains; nothing has re-evaluated the marker yet. - agent.session.pending_prompts.clear(); + park_on_subagents(agent, &["child-1", "child-2", "child-3"]); } - handle( - make_ext_session_notification("sess-park", test_subagent_finished("child-1")), - &mut app, - ); - - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - assert_eq!( - count_parked(agent), - 1, - "the completion re-evaluates the withheld park" - ); - assert_eq!( - parked_marker_messages(agent), - vec!["Worked for 0.0s".to_string()], - ); - } - - #[test] - fn consecutive_subagent_finishes_leave_single_parked_marker() { - let mut app = make_app_with_agent("sess-park"); - let marker_id = { - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - park_on_subagents(agent, &["child-1", "child-2", "child-3"]) - }; - for child in ["child-1", "child-1", "child-2", "child-3"] { handle( make_ext_session_notification("sess-park", test_subagent_finished(child)), @@ -189,489 +112,41 @@ } let agent = app.agents.get_mut(&AgentId(0)).unwrap(); assert_eq!( - parked_marker_messages(agent), - vec!["Worked for 0.0s".to_string()], - "subagent finishes never re-push or mutate the park marker" + count_turn_markers(agent), + 0, + "subagent finishes never write a marker mid-park" ); - assert_eq!(parked_marker_ids(agent), vec![marker_id]); } - /// A re-park after new parent output (text / thought / tool) is a new - /// park episode: the wait-state update that creates the second wait - /// pushes a fresh marker (epoch mismatch), while completions within one - /// episode never re-push. #[test] - fn parent_text_thought_and_tool_output_start_new_park_episodes() { + fn repark_after_parent_output_stays_markerless() { use crate::acp::meta::NotificationMeta; - use crate::app::agent_view::test_fixtures::simulate_task_output_wait_call; - - crate::appearance::cache::set_show_thinking_blocks(true); - for output_kind in ["text", "thought", "tool"] { - let mut app = make_app_with_agent("sess-park"); - let first_marker_id = { - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - agent.session.state = AgentState::TurnRunning; - agent.session.current_prompt_id = Some("p1".into()); - for child_id in ["child-1", "child-2", "child-3"] { - agent - .subagent_sessions - .insert(child_id.into(), make_subagent_info(child_id)); - } - if output_kind == "tool" { - assert!(agent.session.tracker.handle_update( - acp::SessionUpdate::ToolCall( - acp::ToolCall::new( - acp::ToolCallId::new(std::sync::Arc::from("parent-tool")), - "read_file", - ) - .kind(acp::ToolKind::Read) - .status(acp::ToolCallStatus::InProgress) - .content(vec![]) - .locations(vec![]), - ), - &NotificationMeta::default(), - &mut agent.scrollback, - )); - } - simulate_task_output_wait_call(agent, "wait-1", "not-ours", 30_000); - agent.maybe_push_parked_marker(); - parked_marker_ids(agent)[0] - }; - - handle( - make_ext_session_notification("sess-park", test_subagent_finished("child-1")), - &mut app, - ); - { - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - // Same episode: a repeated push attempt (e.g. another wait - // update restating the same wait) is deduped by epoch. - agent.maybe_push_parked_marker(); - assert_eq!(parked_marker_ids(agent).len(), 1); - - let output = match output_kind { - "text" => acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( - acp::ContentBlock::Text(acp::TextContent::new("parent text")), - )), - "thought" => acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new( - acp::ContentBlock::Text(acp::TextContent::new("parent thought")), - )), - "tool" => acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( - acp::ToolCallId::new(std::sync::Arc::from("parent-tool")), - acp::ToolCallUpdateFields::new() - .status(Some(acp::ToolCallStatus::Completed)), - )), - _ => unreachable!(), - }; - assert!(agent.session.tracker.handle_update( - output, - &NotificationMeta::default(), - &mut agent.scrollback, - )); - simulate_task_output_wait_call(agent, "wait-2", "not-ours", 30_000); - // The wait-state notification path re-evaluates the marker on - // every wait update (`maybe_push_parked_marker` from the ACP - // handler); mirror it for the fixture-driven second wait. - agent.maybe_push_parked_marker(); - } - - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - assert_eq!( - parked_marker_messages(agent), - vec!["Worked for 0.0s".to_string(), "Worked for 0.0s".to_string()], - "{output_kind} output must start a new park episode", - ); - let marker_ids = parked_marker_ids(agent); - assert_eq!(marker_ids.len(), 2); - assert_eq!(marker_ids[0], first_marker_id); - assert_ne!(marker_ids[0], marker_ids[1]); - } - } - - #[test] - fn interjection_suppresses_later_marker_push() { - let mut app = make_app_with_agent("sess-park"); - let marker_id = { - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - park_on_subagents(agent, &["child-1", "child-2", "child-3"]) + use crate::app::agent_view::test_fixtures::{ + complete_task_output_wait_call, count_turn_markers, simulate_task_output_wait_call, }; - handle( - make_ext_session_notification("sess-park", test_subagent_finished("child-1")), - &mut app, - ); - assert!(handle_ext_notification( - &interjection_broadcast("sess-park", "continue differently"), - &mut app, - )); - handle( - make_ext_session_notification("sess-park", test_subagent_finished("child-2")), - &mut app, - ); - - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - assert_eq!( - parked_marker_messages(agent), - vec!["Worked for 0.0s".to_string()], - ); - assert_eq!(parked_marker_ids(agent), vec![marker_id]); - } - - #[test] - fn replayed_subagent_finish_does_not_touch_marker() { - let mut app = make_app_with_agent("sess-park"); - let marker_id = { - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - let marker_id = park_on_subagents(agent, &["child-1", "child-2"]); - agent.session.loading_replay = true; - marker_id - }; - - handle( - make_ext_session_notification("sess-park", test_subagent_finished("child-1")), - &mut app, - ); - - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - assert_eq!( - parked_marker_messages(agent), - vec!["Worked for 0.0s".to_string()], - ); - assert_eq!(parked_marker_ids(agent), vec![marker_id]); - } - - #[test] - fn imminent_subagent_wait_keeps_single_marker() { - use crate::app::agent_view::test_fixtures::simulate_task_output_wait; - - let mut app = make_app_with_agent("sess-park"); - let marker_id = { - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - agent.session.state = AgentState::TurnRunning; - agent.session.current_prompt_id = Some("p1".into()); - for child_id in ["child-1", "child-2"] { - agent - .subagent_sessions - .insert(child_id.into(), make_subagent_info(child_id)); - } - simulate_task_output_wait(agent, "child-1"); - agent.maybe_push_parked_marker(); - parked_marker_ids(agent)[0] - }; - - handle( - make_ext_session_notification("sess-park", test_subagent_finished("child-1")), - &mut app, - ); - - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - assert_eq!( - parked_marker_messages(agent), - vec!["Worked for 0.0s".to_string()], - ); - assert_eq!(parked_marker_ids(agent), vec![marker_id]); - } - - /// Synthetic completions from cold-load reconciliation (`session_restart` - /// signal) finalize quietly — no countdown line, mirroring the suppressed - /// "Task failed" block: nothing happened in THIS session. - #[test] - fn stale_on_load_completion_pushes_no_countdown() { - use crate::app::agent_view::test_fixtures::simulate_task_output_wait; - - let mut app = make_app_with_agent("sess-park"); - { - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - agent.session.state = AgentState::TurnRunning; - agent.session.current_prompt_id = Some("p1".into()); - insert_running_task(agent, "t10", "sleep 10"); - insert_running_task(agent, "t15", "sleep 15"); - simulate_task_output_wait(agent, "t15"); - agent.maybe_push_parked_marker(); - assert!(agent.renders_parked()); - } - handle_ext_notification( - &make_task_completed_notif_with_signal( - "sess-park", - "t10", - "sleep 10", - None, - Some("session_restart"), - ), - &mut app, - ); - // Only the initial parked marker — no countdown re-push. - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - assert_eq!(parked_marker_messages(agent).len(), 1); - } - - /// Task completions with no parked look (running turn chrome is up, or - /// the turn already ended) must not emit countdown lines — the Tasks - /// pane and completion blocks already narrate those states. - #[test] - fn task_completion_without_parked_look_pushes_no_countdown() { - let mut app = make_app_with_agent("sess-live"); - { - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - agent.session.state = AgentState::TurnRunning; - agent.session.current_prompt_id = Some("p1".into()); - insert_running_task(agent, "t10", "sleep 10"); - insert_running_task(agent, "t15", "sleep 15"); - // No wait, no parked marker: chrome is the live turn. - } - handle_ext_notification( - &make_task_completed_notif("sess-live", "t10", "sleep 10", Some(0)), - &mut app, - ); - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - assert!(parked_marker_messages(agent).is_empty()); - } - - // -- imminent waits do not park (awaited work already finished) ---------- - - /// Waiting on a task that already completed: no marker, slot stays free. - #[test] - fn wait_on_already_completed_task_pushes_no_parked_marker() { - use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait}; let mut app = make_app_with_agent("sess-park"); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); agent.session.state = AgentState::TurnRunning; agent.session.current_prompt_id = Some("p1".into()); insert_running_task(agent, "t10", "sleep 10"); - agent.session.bg_tasks.get_mut("t10").unwrap().status = BgTaskStatus::Done; - simulate_task_output_wait(agent, "t10"); - agent.maybe_push_parked_marker(); - - assert_eq!(count_parked(agent), 0, "imminent wait must not park"); - assert!( - agent.parked_wait_marker_for.is_none(), - "slot must stay free for a later genuine park" - ); - assert!(!agent.renders_parked()); - } - - /// A skipped wait leaves the slot free: a later wait on running work in - /// the same turn still parks. - #[test] - fn later_genuine_wait_still_parks_after_imminent_wait_skip() { - use crate::app::agent_view::test_fixtures::{ - complete_task_output_wait_call, count_parked, simulate_task_output_wait_call, - }; - - let mut app = make_app_with_agent("sess-park"); - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - agent.session.state = AgentState::TurnRunning; - agent.session.current_prompt_id = Some("p1".into()); - insert_running_task(agent, "done", "sleep 1"); - agent.session.bg_tasks.get_mut("done").unwrap().status = BgTaskStatus::Done; - - simulate_task_output_wait_call(agent, "wait-1", "done", 30_000); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 0); + simulate_task_output_wait_call(agent, "wait-1", "t10", 30_000); + assert!(agent.renders_parked()); + assert_eq!(count_turn_markers(agent), 0); complete_task_output_wait_call(agent, "wait-1"); - insert_running_task(agent, "live", "sleep 99"); - simulate_task_output_wait_call(agent, "wait-2", "live", 30_000); - agent.maybe_push_parked_marker(); - - assert_eq!(count_parked(agent), 1, "genuine park still renders"); - assert_eq!( - parked_marker_messages(agent), - vec!["Worked for 0.0s".to_string()], - ); - } - - /// `Failed` is terminal for imminence, not just `Done`. - #[test] - fn wait_on_failed_task_pushes_no_parked_marker() { - use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait}; - - let mut app = make_app_with_agent("sess-park"); - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - agent.session.state = AgentState::TurnRunning; - agent.session.current_prompt_id = Some("p1".into()); - insert_running_task(agent, "t10", "sleep 10"); - agent.session.bg_tasks.get_mut("t10").unwrap().status = BgTaskStatus::Failed; - - simulate_task_output_wait(agent, "t10"); - agent.maybe_push_parked_marker(); - - assert_eq!(count_parked(agent), 0, "failed task wait must not park"); - assert!(agent.parked_wait_marker_for.is_none()); - } - - /// Finished-subagent waits do not park — resolved by subagent id, then by - /// child session id. - #[test] - fn wait_on_finished_subagent_pushes_no_parked_marker() { - use crate::app::agent_view::test_fixtures::{ - complete_task_output_wait_call, count_parked, simulate_task_output_wait_call, - }; - - let mut app = make_app_with_agent("sess-park"); - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - agent.session.state = AgentState::TurnRunning; - agent.session.current_prompt_id = Some("p1".into()); - let mut info = make_subagent_info("child-1"); - info.finished = true; - agent.subagent_sessions.insert("child-1".into(), info); - - simulate_task_output_wait_call(agent, "wait-1", "sa-child-1", 30_000); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 0, "finished subagent wait must not park"); - assert!(agent.parked_wait_marker_for.is_none()); - - complete_task_output_wait_call(agent, "wait-1"); - simulate_task_output_wait_call(agent, "wait-2", "child-1", 30_000); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 0, "child-session-id wait must not park"); - assert!(agent.parked_wait_marker_for.is_none()); - } - - /// One unresolvable id among terminal ones keeps the park. - #[test] - fn wait_including_unknown_id_still_parks() { - use crate::acp::meta::NotificationMeta; - use crate::app::agent_view::test_fixtures::count_parked; - - let mut app = make_app_with_agent("sess-park"); - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - agent.session.state = AgentState::TurnRunning; - agent.session.current_prompt_id = Some("p1".into()); - insert_running_task(agent, "done", "sleep 1"); - agent.session.bg_tasks.get_mut("done").unwrap().status = BgTaskStatus::Done; - - let meta = NotificationMeta::default(); - agent.session.handle_update( - acp::SessionUpdate::ToolCall( - acp::ToolCall::new( - acp::ToolCallId::new(std::sync::Arc::from("wait-1")), - "get_command_or_subagent_output", - ) - .kind(acp::ToolKind::Other) - .status(acp::ToolCallStatus::Pending) - .content(vec![]) - .locations(vec![]), - ), - &meta, - &mut agent.scrollback, - ); - agent.session.handle_update( - acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( - acp::ToolCallId::new(std::sync::Arc::from("wait-1")), - acp::ToolCallUpdateFields::new().raw_input(Some(serde_json::json!({ - "task_ids": ["done", "not-ours"], - "timeout_ms": 30_000, - }))), + assert!(agent.session.tracker.handle_update( + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + acp::ContentBlock::Text(acp::TextContent::new("between-parks content")), )), - &meta, + &NotificationMeta::default(), &mut agent.scrollback, - ); - agent.maybe_push_parked_marker(); + )); - assert_eq!(count_parked(agent), 1, "unresolvable id keeps the park"); - } - - #[test] - fn wait_all_with_zero_work_pushes_no_parked_marker() { - use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all}; - - let mut app = make_app_with_agent("sess-park"); - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - agent.session.state = AgentState::TurnRunning; - agent.session.current_prompt_id = Some("p1".into()); - - simulate_wait_all(agent); - agent.maybe_push_parked_marker(); - - assert_eq!(count_parked(agent), 0, "zero-work wait-all must not park"); - assert!(agent.parked_wait_marker_for.is_none()); - } - - #[test] - fn wait_all_with_running_work_still_parks() { - use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all}; - - let mut app = make_app_with_agent("sess-park"); - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - agent.session.state = AgentState::TurnRunning; - agent.session.current_prompt_id = Some("p1".into()); - insert_running_task(agent, "t10", "sleep 10"); - - simulate_wait_all(agent); - agent.maybe_push_parked_marker(); - - assert_eq!(count_parked(agent), 1, "wait-all on live work parks"); - assert_eq!( - parked_marker_messages(agent), - vec!["Worked for 0.0s".to_string()], - ); - } - - /// `SubagentSpawned` arriving after the skipped zero-work wait - /// re-evaluates and restores the park. - #[test] - fn subagent_spawn_after_zero_work_wait_all_restores_park() { - use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all}; - - let mut app = make_app_with_agent("sess-park"); - { - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - agent.session.state = AgentState::TurnRunning; - agent.session.current_prompt_id = Some("p1".into()); - simulate_wait_all(agent); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 0, "zero-work wait-all skipped"); - } - - handle( - make_ext_session_notification( - "sess-park", - test_subagent_spawned("sess-park", "child-1"), - ), - &mut app, - ); - - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - assert_eq!(count_parked(agent), 1, "spawn re-evaluates the skipped park"); - assert_eq!( - parked_marker_messages(agent), - vec!["Worked for 0.0s".to_string()], - ); - } - - /// `x.ai/task_backgrounded` arriving after the skipped zero-work wait - /// re-evaluates and restores the park. - #[test] - fn task_backgrounded_after_zero_work_wait_all_restores_park() { - use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all}; - - let mut app = make_app_with_agent("sess-park"); - { - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - agent.session.state = AgentState::TurnRunning; - agent.session.current_prompt_id = Some("p1".into()); - simulate_wait_all(agent); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 0, "zero-work wait-all skipped"); - } - - handle_ext_notification( - &make_task_backgrounded_notif("sess-park", "tc-late", "t-late", "sleep 99"), - &mut app, - ); - - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - assert_eq!( - count_parked(agent), - 1, - "task registration re-evaluates the skipped park" - ); - assert_eq!( - parked_marker_messages(agent), - vec!["Worked for 0.0s".to_string()], - ); + simulate_task_output_wait_call(agent, "wait-2", "t10", 30_000); + assert!(agent.renders_parked(), "the re-park renders parked again"); + assert_eq!(count_turn_markers(agent), 0, "and still writes no marker"); } #[test] @@ -770,4 +245,3 @@ "an interjection from another pane must render" ); } - diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mod.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mod.rs index 433f98f..7cb9f7f 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mod.rs @@ -239,26 +239,7 @@ pub(super) fn insert_running_task(agent: &mut AgentView, task_id: &str, command: }, ); } -/// Marker texts of all parked blocks in scrollback, in order — one per -/// park episode (re-pushed only after new parent output, i.e. a re-park). -pub(super) fn parked_marker_messages(agent: &AgentView) -> Vec { - (0..agent.scrollback.len()) - .filter_map(|i| match agent.scrollback.get(i).map(|e| &e.block) { - Some(RenderBlock::SessionEvent(b)) if b.parked => Some(b.event.message()), - _ => None, - }) - .collect() -} -pub(super) fn parked_marker_ids(agent: &AgentView) -> Vec { - (0..agent.scrollback.len()) - .filter_map(|i| { - let entry = agent.scrollback.get(i)?; - matches!(&entry.block, RenderBlock::SessionEvent(b) if b.parked) - .then_some(entry.id) - }) - .collect() -} -pub(super) fn park_on_subagents(agent: &mut AgentView, child_ids: &[&str]) -> EntryId { +pub(super) fn park_on_subagents(agent: &mut AgentView, child_ids: &[&str]) { use crate::app::agent_view::test_fixtures::simulate_wait_all; agent.session.state = AgentState::TurnRunning; agent.session.current_prompt_id = Some("p1".into()); @@ -266,9 +247,7 @@ pub(super) fn park_on_subagents(agent: &mut AgentView, child_ids: &[&str]) -> En agent.subagent_sessions.insert(child_id.into(), make_subagent_info(child_id)); } simulate_wait_all(agent); - agent.maybe_push_parked_marker(); assert!(agent.renders_parked()); - parked_marker_ids(agent)[0] } pub(super) fn follow_ups_ext( response_id: &str, diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/turn_completion.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/turn_completion.rs index 797972a..f5fa74d 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/turn_completion.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/turn_completion.rs @@ -231,7 +231,7 @@ } #[test] - fn wake_turn_completed_is_markerless() { + fn silent_wake_turn_completed_is_markerless() { let mut app = make_app_with_agent("sess-wake"); seed_two_bg_tasks(&mut app, "sess-wake"); let len_before = app.agents[&AgentId(0)].scrollback.len(); @@ -254,7 +254,7 @@ assert_eq!( agent.scrollback.len(), len_before, - "a completed wake turn pushes no marker" + "a silent wake turn pushes no marker" ); assert_eq!( agent.watchers().commands, @@ -263,12 +263,65 @@ ); } + #[test] + fn chatty_wake_turn_completed_pushes_one_marker() { + use crate::app::agent_view::test_fixtures::count_turn_markers; + + let mut app = make_app_with_agent("sess-wake"); + let _ = handle( + make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000), + &mut app, + ); + assert_eq!(count_turn_markers(&app.agents[&AgentId(0)]), 0); + + let affected = handle_ext_notification( + &xai_wake_turn_completed_notif("sess-wake", "task-completed-bg1", None), + &mut app, + ); + assert!(affected); + + let agent = app.agents.get(&AgentId(0)).unwrap(); + assert_eq!( + count_turn_markers(agent), + 1, + "a chatty wake closes with exactly one marker" + ); + assert!(matches!( + last_session_event(&agent.scrollback), + Some(SessionEvent::TurnCompleted { .. }) + )); + } + + #[test] + fn duplicate_wake_terminal_pushes_no_second_marker() { + // `finish_wake_turn` snapshots the output epoch, so a duplicate sees no new output. + use crate::app::agent_view::test_fixtures::count_turn_markers; + + let mut app = make_app_with_agent("sess-wake"); + let _ = handle( + make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000), + &mut app, + ); + let _ = handle_ext_notification( + &xai_wake_turn_completed_notif("sess-wake", "task-completed-bg1", None), + &mut app, + ); + assert_eq!(count_turn_markers(&app.agents[&AgentId(0)]), 1); + + let _ = handle_ext_notification( + &xai_wake_turn_completed_notif("sess-wake", "task-completed-bg1", None), + &mut app, + ); + assert_eq!( + count_turn_markers(&app.agents[&AgentId(0)]), + 1, + "a duplicate wake terminal must not push a second marker" + ); + } + #[test] fn wake_terminal_finishes_in_flight_streamed_entry() { - // A wake turn streams its response, then its terminal lands: the - // terminal is the ONLY flush site (wake turns skip PromptResponse), - // so the streamed entry must be finished — not left spinning until - // the next turn's stream start. Dead wakes take the same path. + // The terminal is a wake's ONLY flush site (wakes skip PromptResponse). let mut app = make_app_with_agent("sess-wake"); let _ = handle( make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000), @@ -291,9 +344,7 @@ #[test] fn wake_turn_completed_in_replay_only_records_pid() { - // The replay arm is untouched: a wake pid seen during a load's replay - // records adoption state and pushes nothing (markers are client-local - // and never replayed). + // Markers are client-local and never replayed. let mut app = make_app_with_agent("sess-wake"); app.agents .get_mut(&AgentId(0)) @@ -324,9 +375,7 @@ #[test] fn scheduler_fired_turn_completed_keeps_adopted_path() { - // `/loop` turns are synthetic but CLIENT-driven with a real finalize - // path — they must not take the wake-marker shortcut. Idle driver + - // scheduler pid → the shared finalize ignores it, no marker. + // `/loop` turns are client-driven with a real finalize path — never the wake shortcut. let mut app = make_app_with_agent("sess-cron"); let len_before = app.agents[&AgentId(0)].scrollback.len(); @@ -344,14 +393,209 @@ } #[test] - fn failed_wake_turn_keeps_markerless_shape() { - // "Worked for" would lie about an errored/cancelled wake turn, and - // the cancel/failure UX is driver-side context this signal lacks — - // those stop reasons keep today's markerless shape. + fn silent_errored_wake_pushes_failure_marker() { + // Failures surface even when invisible: the standing instruction silently stopped. let mut app = make_app_with_agent("sess-wake"); let len_before = app.agents[&AgentId(0)].scrollback.len(); - for stop_reason in ["error", "cancelled", "rate_limit"] { + let _ = handle_ext_notification( + &xai_turn_completed_notif("sess-wake", "task-completed-bg1", "error", false), + &mut app, + ); + + let agent = app.agents.get(&AgentId(0)).unwrap(); + assert_eq!(agent.scrollback.len(), len_before + 1); + assert!(matches!( + last_session_event(&agent.scrollback), + Some(SessionEvent::TurnFailed { .. }) + )); + } + + #[test] + fn silent_errored_wake_ignores_stale_turn_start_ms() { + // A silent wake streamed no deltas, so the stored `turn_start_ms` is an earlier turn's. + let mut app = make_app_with_agent("sess-wake"); + app.agents.get_mut(&AgentId(0)).unwrap().turn_start_ms = + Some(chrono::Utc::now().timestamp_millis() - 600_000); + + let _ = handle_ext_notification( + &xai_turn_completed_notif("sess-wake", "task-completed-bg1", "error", false), + &mut app, + ); + + let agent = app.agents.get(&AgentId(0)).unwrap(); + assert!(matches!( + last_session_event(&agent.scrollback), + Some(SessionEvent::TurnFailed { elapsed: None, .. }) + )); + } + + #[test] + fn goal_terminal_snapshots_epoch_so_next_silent_wake_stays_markerless() { + // A dirty output epoch made the NEXT silent wake inherit the goal turn's output. + use crate::app::agent_view::test_fixtures::count_turn_markers; + + let mut app = make_app_with_agent("sess-wake"); + let _ = handle( + make_viewer_chunk_with_turn_start("sess-wake", "goal-summary-g1", 5_000), + &mut app, + ); + let _ = handle_ext_notification( + &xai_turn_completed_notif("sess-wake", "goal-summary-g1", "end_turn", false), + &mut app, + ); + let len_before = app.agents[&AgentId(0)].scrollback.len(); + + let _ = handle_ext_notification( + &xai_turn_completed_notif("sess-wake", "task-completed-bg1", "end_turn", false), + &mut app, + ); + + let agent = app.agents.get(&AgentId(0)).unwrap(); + assert_eq!( + agent.scrollback.len(), + len_before, + "a silent wake after a goal turn must not inherit its output" + ); + assert_eq!(count_turn_markers(agent), 0); + } + + #[test] + fn errored_wake_terminal_during_local_turn_still_pushes_failure() { + // Failure visibility survives the busy skip: no tracker finish, no + // elapsed (the anchor is the local turn's), but the row must land. + use crate::app::agent::AgentState; + + let mut app = make_app_with_agent("sess-wake"); + app.agents.get_mut(&AgentId(0)).unwrap().session.state = AgentState::TurnRunning; + let len_before = app.agents[&AgentId(0)].scrollback.len(); + + for _ in 0..2 { + let _ = handle_ext_notification( + &xai_turn_completed_notif("sess-wake", "task-completed-bg1", "error", false), + &mut app, + ); + } + + let agent = app.agents.get(&AgentId(0)).unwrap(); + assert_eq!(agent.scrollback.len(), len_before + 1, "one row, deduped"); + assert!(matches!( + last_session_event(&agent.scrollback), + Some(SessionEvent::TurnFailed { elapsed: None, .. }) + )); + } + + #[test] + fn wake_terminal_during_command_snapshots_epoch_for_next_silent_wake() { + // A client command (e.g. /compact) skips the wake finish but must not + // leave the epoch dirty: the next silent wake would claim the skipped + // wake's output. + use crate::app::agent::{AgentCommand, AgentState}; + use crate::app::agent_view::test_fixtures::count_turn_markers; + + let mut app = make_app_with_agent("sess-wake"); + let _ = handle( + make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000), + &mut app, + ); + app.agents.get_mut(&AgentId(0)).unwrap().session.state = AgentState::CommandRunning { + command: AgentCommand::Compact, + started_at: std::time::Instant::now(), + }; + let _ = handle_ext_notification( + &xai_turn_completed_notif("sess-wake", "task-completed-bg1", "end_turn", false), + &mut app, + ); + app.agents.get_mut(&AgentId(0)).unwrap().session.state = AgentState::Idle; + let len_before = app.agents[&AgentId(0)].scrollback.len(); + + let _ = handle_ext_notification( + &xai_turn_completed_notif("sess-wake", "task-completed-bg2", "end_turn", false), + &mut app, + ); + + let agent = app.agents.get(&AgentId(0)).unwrap(); + assert_eq!( + agent.scrollback.len(), + len_before, + "silent wake after a command-skipped terminal must stay markerless" + ); + assert_eq!(count_turn_markers(agent), 0); + } + + #[test] + fn chatty_wake_with_foreign_turn_start_anchor_omits_elapsed() { + // `turn_start_ms` stamped by another prompt's deltas must not become + // this wake's elapsed. + let mut app = make_app_with_agent("sess-wake"); + let _ = handle( + make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 600_000), + &mut app, + ); + + let _ = handle_ext_notification( + &xai_turn_completed_notif("sess-wake", "task-completed-bg2", "end_turn", false), + &mut app, + ); + + let agent = app.agents.get(&AgentId(0)).unwrap(); + assert!(matches!( + last_session_event(&agent.scrollback), + Some(SessionEvent::TurnCompleted { elapsed: None }) + )); + } + + #[test] + fn silent_errored_wake_after_goal_turn_has_no_elapsed() { + let mut app = make_app_with_agent("sess-wake"); + let _ = handle( + make_viewer_chunk_with_turn_start("sess-wake", "goal-summary-g1", 5_000), + &mut app, + ); + let _ = handle_ext_notification( + &xai_turn_completed_notif("sess-wake", "goal-summary-g1", "end_turn", false), + &mut app, + ); + + let _ = handle_ext_notification( + &xai_turn_completed_notif("sess-wake", "task-completed-bg1", "error", false), + &mut app, + ); + + let agent = app.agents.get(&AgentId(0)).unwrap(); + assert!(matches!( + last_session_event(&agent.scrollback), + Some(SessionEvent::TurnFailed { elapsed: None, .. }) + )); + } + + #[test] + fn duplicate_errored_wake_terminal_pushes_one_failure_marker() { + // Failures bypass the output-epoch dedupe, so duplicates are deduped by prompt id. + let mut app = make_app_with_agent("sess-wake"); + let len_before = app.agents[&AgentId(0)].scrollback.len(); + + for _ in 0..2 { + let _ = handle_ext_notification( + &xai_turn_completed_notif("sess-wake", "task-completed-bg1", "error", false), + &mut app, + ); + } + + assert_eq!( + app.agents[&AgentId(0)].scrollback.len(), + len_before + 1, + "one failure marker for the wake, duplicates dropped" + ); + } + + #[test] + fn silent_cancelled_or_rate_limited_wake_stays_markerless() { + // Rate limits ride the retry notifications instead, matching the real-turn rails. + let mut app = make_app_with_agent("sess-wake"); + let len_before = app.agents[&AgentId(0)].scrollback.len(); + + for stop_reason in ["cancelled", "rate_limit"] { let _ = handle_ext_notification( &xai_turn_completed_notif("sess-wake", "task-completed-bg1", stop_reason, false), &mut app, @@ -361,10 +605,50 @@ assert_eq!( app.agents[&AgentId(0)].scrollback.len(), len_before, - "non-completion wake terminals push nothing" + "cancelled/rate-limited silent wake terminals push nothing" ); } + #[test] + fn chatty_rate_limited_wake_closes_with_failure_marker() { + let mut app = make_app_with_agent("sess-wake"); + let _ = handle( + make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000), + &mut app, + ); + + let _ = handle_ext_notification( + &xai_turn_completed_notif("sess-wake", "task-completed-bg1", "rate_limit", false), + &mut app, + ); + + let agent = app.agents.get(&AgentId(0)).unwrap(); + assert!(matches!( + last_session_event(&agent.scrollback), + Some(SessionEvent::TurnFailed { .. }) + )); + } + + #[test] + fn chatty_errored_wake_pushes_failure_marker_not_worked_for() { + let mut app = make_app_with_agent("sess-wake"); + let _ = handle( + make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000), + &mut app, + ); + + let _ = handle_ext_notification( + &xai_turn_completed_notif("sess-wake", "task-completed-bg1", "error", false), + &mut app, + ); + + let agent = app.agents.get(&AgentId(0)).unwrap(); + assert!(matches!( + last_session_event(&agent.scrollback), + Some(SessionEvent::TurnFailed { .. }) + )); + } + #[test] fn dead_wake_pushes_no_status_line() { let mut app = make_app_with_agent("sess-wake"); @@ -395,11 +679,8 @@ #[test] fn wake_terminal_during_local_turn_pushes_nothing() { - // Wire interleave: wake turn W streams (pager idle), the user sends a - // prompt locally (TurnRunning), then FIFO delivers W's terminal - // before the new turn's deltas. A foreign "Worked for" under the - // fresh prompt would misattribute — the local turn pushes its own - // marker when it ends. + // FIFO can deliver a wake's terminal after a fresh local prompt starts; a + // foreign "Worked for" under that prompt would misattribute. let mut app = make_app_with_agent("sess-wake"); seed_two_bg_tasks(&mut app, "sess-wake"); { @@ -429,10 +710,6 @@ #[test] fn wake_terminal_leaves_real_turn_stash_pending() { - // Stop-hook stash semantics belong to real turns: a stash stamped - // with a REAL turn's pid must survive a wake turn's (markerless) - // terminal untouched — no fold, no standalone flush — and wait for - // its own marker rail. use crate::scrollback::blocks::tool::{HookRunEntry, HookRunStatus}; let mut app = make_app_with_agent("sess-wake"); { @@ -900,9 +1177,7 @@ #[test] fn will_wake_flag_is_ignored_wire_compat_pin() { - // `will_wake` is a wire-compat field the TUI no longer reads: a - // stamped completion must behave exactly like an unstamped one - // (chip-only). Pins the "ignored, not load-bearing" contract. + // `will_wake` is a wire-compat field the TUI no longer reads. let mut app = make_app_with_agent("sess-wake-skip"); seed_two_bg_tasks(&mut app, "sess-wake-skip"); @@ -1096,10 +1371,7 @@ #[test] fn wake_stop_hooks_render_standalone_at_arrival() { - // Wake turns close markerless, so a wake-pid stop batch has no marker - // to fold into — it renders standalone the moment it arrives, whether - // it beats or trails its wake TurnCompleted. Never stashed: a stash - // keyed to a wake pid would wait for a marker that never comes. + // Never stashed: a stash keyed to a wake pid could wait for a marker that never comes. let mut app = make_app_with_agent("sess-wake-idle"); // Hook beats the wake terminal. @@ -1141,9 +1413,6 @@ #[test] fn wake_stop_hooks_never_stash_under_local_turn() { - // A wake batch landing while a LOCAL turn runs must not stash under - // (or fold onto) the unrelated local turn — it renders standalone, - // and the local turn's marker rail stays clean. let mut app = make_app_with_agent("sess-wake-local"); { let agent = app.agents.get_mut(&AgentId(0)).unwrap(); diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/interactions.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/interactions.rs index 11ae62d..7f644bf 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/interactions.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/interactions.rs @@ -264,6 +264,7 @@ impl AgentView { } } qv.focus = QuestionFocus::Navigation; + self.last_prompt_click_ms = None; } return InputOutcome::Changed; } @@ -276,6 +277,7 @@ impl AgentView { } if key!('c', CONTROL).matches(key) { qv.focus = QuestionFocus::Navigation; + self.last_prompt_click_ms = None; return InputOutcome::Changed; } match self.prompt.route_enter(key) { @@ -306,6 +308,7 @@ impl AgentView { } } qv.focus = QuestionFocus::Navigation; + self.last_prompt_click_ms = None; let last = qv.questions.len().saturating_sub(1); if qv.active_tab < last { self.swap_question_freeform(); @@ -349,7 +352,7 @@ impl AgentView { && matches!(key.code, KeyCode::Char(c) if c != ' ') { let text = qv.activate_freeform_input(); - self.prompt.set_text(&text); + self.prompt.set_text_preserving(&text); let _ = self.prompt.handle_key(key); return InputOutcome::Changed; } @@ -407,7 +410,7 @@ impl AgentView { KeyCode::Char(' ') => { if qv.is_on_freeform_row() { let text = qv.activate_freeform_input(); - self.prompt.set_text(&text); + self.prompt.set_text_preserving(&text); } else { let active = qv.active_tab; let cursor = qv.cursor(); @@ -427,7 +430,7 @@ impl AgentView { KeyCode::Enter => { if qv.is_on_freeform_row() { let text = qv.activate_freeform_input(); - self.prompt.set_text(&text); + self.prompt.set_text_preserving(&text); } else { let cursor = qv.cursor(); let active = qv.active_tab; @@ -448,7 +451,7 @@ impl AgentView { let freeform_idx = qv.total_items(qv.active_tab).saturating_sub(1); qv.set_cursor(freeform_idx); let text = qv.activate_freeform_input(); - self.prompt.set_text(&text); + self.prompt.set_text_preserving(&text); } } KeyCode::Char('l') | KeyCode::Char(']') | KeyCode::Right @@ -590,6 +593,7 @@ impl AgentView { *sel = None; } qv.focus = crate::views::question_view::QuestionFocus::Navigation; + self.last_prompt_click_ms = None; } let key_event = KeyEvent::new( if key_ch == '\n' { @@ -669,6 +673,11 @@ impl AgentView { .contains((mouse.column, mouse.row).into()) { let _ = self.prompt.handle_mouse(mouse); + if self.prompt_click_is_double() + && self.prompt.expand_paste_element_at_cursor() + { + self.prompt.refresh_slash(&self.session.models); + } return InputOutcome::Changed; } let idx = qv.active_tab; @@ -687,6 +696,7 @@ impl AgentView { *sel = None; } qv.focus = crate::views::question_view::QuestionFocus::Navigation; + self.last_prompt_click_ms = None; } let prompt_area = self.pane_areas.prompt; let footer_h = 3u16; @@ -729,7 +739,7 @@ impl AgentView { .get(active_tab) .cloned() .unwrap_or_default(); - self.prompt.set_text(&text); + self.prompt.set_text_preserving(&text); qv.focus = crate::views::question_view::QuestionFocus::InputMode; } return InputOutcome::Changed; @@ -812,7 +822,7 @@ impl AgentView { .get(tab) .cloned() .unwrap_or_default(); - self.prompt.set_text(&text); + self.prompt.set_text_preserving(&text); qv.focus = QuestionFocus::InputMode; } } @@ -1028,7 +1038,7 @@ impl AgentView { .get(qv.active_tab) .map(|s| s.as_str()) .unwrap_or(""); - self.prompt.set_text(new_text); + self.prompt.set_text_preserving(new_text); } /// Dismiss (hide) the question view without submitting answers. /// @@ -1195,6 +1205,7 @@ impl AgentView { self.hit_question_scrollbar.clear(); self.inline_prompt_area = None; self.last_question_click = None; + self.last_prompt_click_ms = None; } /// Answer the ACTIVE question of this agent's pending /// `AskUserQuestion` from the dashboard peek panel. @@ -1684,7 +1695,7 @@ mod question_no_freeform_tests { id: None, } } - fn open_question(agent: &mut AgentView, no_freeform: bool) { + pub(super) fn open_question(agent: &mut AgentView, no_freeform: bool) { let state = QuestionViewState::new( "tc-upsell".into(), vec![upsell_question()], @@ -1698,7 +1709,7 @@ mod question_no_freeform_tests { } /// Draw one 80x30 frame so `pane_areas` and `question_scroll_region` /// hold the real rendered layout the mouse handler hit-tests against. - fn draw_frame(agent: &mut AgentView) { + pub(super) fn draw_frame(agent: &mut AgentView) { let area = Rect::new(0, 0, 80, 30); let reg = ActionRegistry::defaults(); let bundle = crate::app::bundle::BundleState::default(); @@ -1719,7 +1730,7 @@ mod question_no_freeform_tests { crate::app::agent_view::AppRenderParams::default(), ); } - fn down(col: u16, row: u16) -> MouseEvent { + pub(super) fn down(col: u16, row: u16) -> MouseEvent { MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column: col, @@ -1735,7 +1746,7 @@ mod question_no_freeform_tests { modifiers: KeyModifiers::empty(), } } - fn qv(agent: &AgentView) -> &QuestionViewState { + pub(super) fn qv(agent: &AgentView) -> &QuestionViewState { agent.question_view.as_ref().expect("question view open") } /// Clicking the empty rows under the last option (option gap, footer) @@ -1847,3 +1858,129 @@ mod question_no_freeform_tests { assert_eq!(qv(&agent).focus, QuestionFocus::InputMode); } } +#[cfg(test)] +mod question_freeform_chip_tests { + //! Paste-chip round trip through the question freeform input: + //! re-entering input mode used to reload the unchanged draft with a + //! wholesale `set_text`, expanding every chip into raw text. + use super::super::test_fixtures::make_agent; + use super::question_no_freeform_tests::{down, draw_frame, open_question, qv}; + use crate::app::agent_view::AgentView; + use crate::views::prompt_widget::KIND_PASTE; + use crate::views::question_view::QuestionFocus; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + const PASTE: &str = "line 1\nline 2\nline 3\nline 4\nline 5"; + fn paste_chip_count(agent: &AgentView) -> usize { + agent + .prompt + .textarea() + .elements() + .iter() + .filter(|e| e.kind == KIND_PASTE) + .count() + } + /// Multi-line paste folds into a chip; Esc out and Enter back in must + /// keep the chip folded (not raw expanded text), and the string slot + /// keeps the full paste for the submit payload. + #[test] + fn paste_chip_survives_input_mode_round_trip() { + let mut agent = make_agent(); + open_question(&mut agent, false); + let z = KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE); + let _ = agent.handle_question_key(&z); + assert_eq!(qv(&agent).focus, QuestionFocus::InputMode); + let _ = agent.prompt.handle_paste(PASTE); + assert_eq!(paste_chip_count(&agent), 1, "paste must fold into a chip"); + let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + let _ = agent.handle_question_key(&esc); + assert_eq!(qv(&agent).focus, QuestionFocus::Navigation); + assert_eq!(qv(&agent).per_question_freeform[0], PASTE); + assert!(qv(&agent).per_question_freeform_selected[0]); + let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + let _ = agent.handle_question_key(&enter); + assert_eq!(qv(&agent).focus, QuestionFocus::InputMode); + assert_eq!( + paste_chip_count(&agent), + 1, + "re-entering input mode must keep the folded chip" + ); + assert_eq!(agent.prompt.text(), PASTE, "buffer text must round-trip"); + } + /// A slot rewritten by another surface (e.g. the dashboard peek answer + /// path) no longer matches the live draft, so re-entry must take the + /// normal `set_text` path and show the rewritten slot. + #[test] + fn rewritten_slot_replaces_stale_draft() { + let mut agent = make_agent(); + open_question(&mut agent, false); + let z = KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE); + let _ = agent.handle_question_key(&z); + let _ = agent.prompt.handle_paste(PASTE); + let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + let _ = agent.handle_question_key(&esc); + agent.question_view.as_mut().unwrap().per_question_freeform[0] = "peek answer".to_string(); + let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + let _ = agent.handle_question_key(&enter); + assert_eq!(qv(&agent).focus, QuestionFocus::InputMode); + assert_eq!( + agent.prompt.text(), + "peek answer", + "a stale draft must not shadow the rewritten slot" + ); + assert_eq!(paste_chip_count(&agent), 0); + } + /// Double-click on the chip inside the question freeform input expands + /// it, exactly like the main prompt; a single click must not. + #[test] + fn double_click_expands_chip_in_question_input() { + let mut agent = make_agent(); + open_question(&mut agent, false); + let z = KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE); + let _ = agent.handle_question_key(&z); + let _ = agent.prompt.handle_paste(PASTE); + assert_eq!(paste_chip_count(&agent), 1); + draw_frame(&mut agent); + let ta = agent.prompt.textarea_area(); + assert!(ta.area() > 0, "inline textarea must have rendered"); + let (col, row) = (ta.x + 2, ta.y); + let _ = agent.handle_question_mouse(&down(col, row)); + assert_eq!( + paste_chip_count(&agent), + 1, + "a single click must not expand the chip" + ); + let _ = agent.handle_question_mouse(&down(col, row)); + assert_eq!( + paste_chip_count(&agent), + 0, + "double-click must expand the chip" + ); + assert_eq!(agent.prompt.text(), PASTE, "content inlined as plain text"); + assert_eq!( + qv(&agent).focus, + QuestionFocus::InputMode, + "expanding must not leave input mode" + ); + } + /// A textarea click from before leaving InputMode must not pair with + /// the first click after re-entry as a double-click (exits clear the + /// pairing timer). + #[test] + fn click_before_exit_does_not_pair_with_click_after_reentry() { + let mut agent = make_agent(); + open_question(&mut agent, false); + let z = KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE); + let _ = agent.handle_question_key(&z); + let _ = agent.prompt.handle_paste(PASTE); + draw_frame(&mut agent); + let ta = agent.prompt.textarea_area(); + let (col, row) = (ta.x + 2, ta.y); + let _ = agent.handle_question_mouse(&down(col, row)); + let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + let _ = agent.handle_question_key(&esc); + let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + let _ = agent.handle_question_key(&enter); + let _ = agent.handle_question_mouse(&down(col, row)); + assert_eq!(paste_chip_count(&agent), 1, "chip must stay folded"); + } +} diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/mod.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/mod.rs index f887187..93c14f3 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/mod.rs @@ -737,35 +737,6 @@ pub(crate) enum AgentDeferredSend { /// Ctrl+Enter — a mid-turn interjection. Interject, } -/// How the parked-marker slot was consumed. Both variants carry the turn's -/// prompt id and both keep the parked (idle) chrome. `Rendered` markers are -/// one-per-park-episode — a re-park after new parent output (epoch bump) -/// pushes a fresh one (see `maybe_push_parked_marker`); `Forgone` (an -/// interjection continued the parked turn) is final — a later "Worked for" -/// line would land below the interjected message, flipping the transcript. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ParkedMarkerSlot { - /// A "Worked for X" marker block was pushed. - Rendered { - prompt_id: String, - /// The parent-output boundary at push time: chips/completions landing - /// under the marker don't bump it, so a matching epoch means "same - /// park episode — don't re-push". - agent_output_epoch: u64, - }, - /// The marker was forgone: an interjection continued the parked turn. - Forgone(String), -} -impl ParkedMarkerSlot { - /// The prompt id the slot was consumed for, regardless of variant. - pub(crate) fn prompt_id(&self) -> &str { - match self { - ParkedMarkerSlot::Rendered { prompt_id, .. } | ParkedMarkerSlot::Forgone(prompt_id) => { - prompt_id - } - } - } -} pub struct AgentView { pub session: AgentSession, pub(crate) session_binding_epoch: u32, @@ -848,6 +819,10 @@ pub struct AgentView { /// turn that already ended (otherwise the viewer re-strands on "Waiting…"). /// Reset at the start of every load so it never leaks across loads. pub(crate) replayed_terminal_prompts: HashSet, + /// Wake prompt id whose failure marker already rendered — a re-delivered + /// errored wake terminal must not stack a second "Turn failed" row (the + /// output-epoch dedupe only covers chatty closes; failures bypass it). + pub(crate) failed_wake_marker_for: Option, pub active_pane: AgentPane, /// Current mode of the prompt widget (normal vs editing a queued prompt). pub prompt_mode: PromptMode, @@ -916,10 +891,6 @@ pub struct AgentView { pub cleared_workflow_runs: std::collections::HashSet, pub show_workflows: bool, pub workflows_view: crate::views::workflows::WorkflowsViewState, - /// The consumed parked-wait marker slot for the current turn, if any. - /// Keyed by prompt id: a new turn naturally invalidates the slot with no - /// explicit clear site. See [`ParkedMarkerSlot`]. - pub(crate) parked_wait_marker_for: Option, /// Live `stop`/`stop_failure` hook runs held for the turn's terminal /// marker (driver order: the hooks arrive before the `PromptResponse` /// that pushes it). Consumed or flushed by `push_turn_terminal_marker`; @@ -938,6 +909,10 @@ pub struct AgentView { /// UTC ms when the current turn started (`turnStartMs` from notification meta). /// Used for turn elapsed display. pub turn_start_ms: Option, + /// Prompt id the stored `turn_start_ms` belongs to (stamped together from + /// the same delta meta): wake markers may only claim an elapsed whose + /// anchor is provably their own turn's. + pub turn_start_ms_prompt: Option, /// Local wall-clock time when the current turn started. /// Set by `maybe_drain_queue` when a prompt is sent. Used to compute /// elapsed time for "Worked for Xm Ys" system messages. @@ -2422,15 +2397,17 @@ pub(crate) mod test_fixtures { child_updates_replayed: false, } } - /// Count of parked ("Worked for X") marker blocks in the agent's - /// scrollback. - pub fn count_parked(agent: &AgentView) -> usize { + /// Count of "Worked for X" (`TurnCompleted`) marker blocks in the + /// agent's scrollback. + pub fn count_turn_markers(agent: &AgentView) -> usize { use crate::scrollback::block::RenderBlock; + use crate::scrollback::blocks::SessionEvent; (0..agent.scrollback.len()) .filter(|i| { matches!( agent.scrollback.get(*i).map(|e| &e.block), - Some(RenderBlock::SessionEvent(b)) if b.parked + Some(RenderBlock::SessionEvent(b)) + if matches!(b.event, SessionEvent::TurnCompleted { .. }) ) }) .count() diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/queue.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/queue.rs index 8164002..7dbae19 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/queue.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/queue.rs @@ -3,7 +3,7 @@ #[cfg(test)] use super::test_fixtures; -use super::{AgentPane, AgentView, ParkedMarkerSlot, PromptMode, overlay_action_to_outcome}; +use super::{AgentPane, AgentView, PromptMode, overlay_action_to_outcome}; use crate::actions::ActionRegistry; use crate::app::actions::Action; use crate::app::app_view::InputOutcome; @@ -76,8 +76,8 @@ impl AgentView { /// the wait as user-interruptible would lie there). /// /// Gates Enter interjecting instead of queueing and the parked queue - /// drain. The stopped-session *rendering* additionally requires the - /// parked-marker slot to be consumed — see [`Self::renders_parked`]. + /// drain. The stopped-session *rendering* additionally excludes subagent + /// waits — see [`Self::renders_parked`]. /// Purely view-derived — reading it has no turn-lifecycle side effects. pub(crate) fn is_parked_on_sendable_wait(&self) -> bool { crate::views::turn_status::is_sendable_wait(&self.resolve_turn_activity()) @@ -114,7 +114,7 @@ impl AgentView { } /// The current wait is a foreground subagent await — sendable, but excluded - /// from the parked marker (the parent is blocked, not completed; the + /// from the parked look (the parent is blocked, not completed; the /// subagent reports its own progress). pub(crate) fn is_waiting_on_subagent(&self) -> bool { use crate::acp::tracker::{TurnActivity, WaitingReason}; @@ -124,166 +124,6 @@ impl AgentView { ) } - /// The wait can only return imminently: every awaited id is already - /// terminal, or a wait-all sees zero running work. Unknown ids and Sleep - /// are never imminent. Callers must pre-gate on - /// `is_parked_on_sendable_wait` — this predicate ignores `waits`. - fn parked_wait_resolves_imminently(&self) -> bool { - use crate::acp::tracker::{TurnActivity, WaitingReason}; - match self.resolve_turn_activity() { - Some(TurnActivity::Waiting(WaitingReason::TaskOutput { task_ids, .. })) => { - !task_ids.is_empty() && task_ids.iter().all(|id| self.awaited_id_is_terminal(id)) - } - // The tracker drops wait_commands_or_subagents' explicit task_ids; - // zero visible work is the only signal available here. - Some(TurnActivity::Waiting(WaitingReason::TasksComplete)) => { - self.watchers().awaitable_work() == 0 - } - _ => false, - } - } - - /// Terminal work behind an awaited id: bg task by task id, else subagent - /// by child session id or subagent id. Unknown → `false`. - fn awaited_id_is_terminal(&self, id: &str) -> bool { - if let Some(task) = self.session.bg_tasks.get(id) { - return task.status != crate::app::agent::BgTaskStatus::Running; - } - self.subagent_sessions - .get(id) - .or_else(|| { - self.subagent_sessions - .values() - .find(|info| info.subagent_id.as_ref() == id) - }) - .is_some_and(|info| !info.is_running()) - } - - /// Push a "Worked for X" marker when the turn parks on a sendable wait — - /// the transcript boundary explaining the idle-looking chrome. One marker - /// per park episode: same agent-output epoch as the rendered slot means - /// no re-push (chips/completions don't bump it); an epoch bump means the - /// wait resumed and re-parked, which pushes a fresh marker. Completion - /// rails also call this to re-eval a park withheld at park time (e.g. - /// held queue since drained). - /// - /// Called from the ACP notification path — not the draw path — so - /// background tabs and minimal mode stamp the park at its true moment. A - /// [`ParkedMarkerSlot::Forgone`] slot stays silent for the rest of the - /// turn (see [`Self::suppress_parked_marker_on_interject`]). UI-only: no - /// turn-lifecycle event, no stop hooks; the completion folds into an - /// uncommitted tail parked marker, else prints fresh (minimal-mode - /// commits are print-once). - pub(crate) fn maybe_push_parked_marker(&mut self) { - if !self.is_parked_on_sendable_wait() - || self.is_waiting_on_subagent() - || self.has_held_user_queue() - { - return; - } - let Some(prompt_id) = self.session.current_prompt_id.clone() else { - return; - }; - match &self.parked_wait_marker_for { - // Interjection ordering: forgone is final for the turn. - Some(ParkedMarkerSlot::Forgone(pid)) if *pid == prompt_id => return, - // Same park episode (no parent output since the marker): the one - // marker already explains this park — chips landing below it - // must not re-push. - Some(ParkedMarkerSlot::Rendered { - prompt_id: pid, - agent_output_epoch, - .. - }) if *pid == prompt_id - && *agent_output_epoch == self.session.tracker.agent_output_epoch() => - { - return; - } - // A tail user prompt after a rendered marker is an interjection: - // a marker line beneath it would flip the transcript. - Some(ParkedMarkerSlot::Rendered { prompt_id: pid, .. }) - if *pid == prompt_id && self.tail_is_user_prompt() => - { - return; - } - _ => {} - } - // Below the slot dedupe: a rendered park would otherwise log a false - // "skipped" on every subsequent update. - if self.parked_wait_resolves_imminently() { - tracing::debug!( - target: "prompt.parked_marker", - "parked marker skipped: awaited work already finished, wait resolves imminently" - ); - return; - } - self.push_parked_marker_block(prompt_id); - } - - /// The transcript tail is a user-authored prompt row. - fn tail_is_user_prompt(&self) -> bool { - matches!( - self.scrollback.last().map(|entry| &entry.block), - Some(crate::scrollback::block::RenderBlock::UserPrompt(_)) - ) - } - - /// The parked marker block shape: a `TurnCompleted` marker flagged - /// `parked` (renders mid-turn, never accepts stop hooks). - fn push_parked_marker_block(&mut self, prompt_id: String) { - let agent_output_epoch = self.session.tracker.agent_output_epoch(); - let mut block = crate::scrollback::blocks::SessionEventBlock::new( - crate::scrollback::blocks::SessionEvent::TurnCompleted { - // Unknown elapsed renders as "Worked for 0.0s" rather than - // falling back to `None`'s bare "Turn completed." — the park - // boundary should read like every other turn marker. - elapsed: Some(self.turn_elapsed().unwrap_or_default()), - }, - ); - block.parked = true; - block.prompt_id = Some(prompt_id.clone()); - self.scrollback - .push_block(crate::scrollback::block::RenderBlock::SessionEvent(block)); - self.parked_wait_marker_for = Some(ParkedMarkerSlot::Rendered { - prompt_id, - agent_output_epoch, - }); - } - - /// Consume the parked-marker slot as forgone when an interjection lands - /// while the turn is parked on a sendable wait: the turn visibly continues - /// below the user's message, so the withheld "Worked for … still - /// running." marker must never render under it (it would read as the turn - /// completing *after* the user's follow-up — flipped ordering). A no-op - /// when the marker already rendered (slot already stamped) or the turn is - /// not parked (a plain mid-turn interjection keeps a later park's marker). - /// - /// Accepted edge: if the interject send later FAILS while the wait is - /// still parked (`TaskResult::InterjectFailed` requeues the payload), the - /// slot stays consumed — idle chrome without a marker until the wait - /// ends. Un-consuming would recreate the flipped ordering under the - /// already-rendered optimistic block. - pub(crate) fn suppress_parked_marker_on_interject(&mut self) { - if self.is_parked_on_sendable_wait() - && let Some(prompt_id) = self.session.current_prompt_id.clone() - { - // Never downgrade a Rendered slot: with the marker on screen the - // ordering is already correct, and its countdown may keep ticking. - if self - .parked_wait_marker_for - .as_ref() - .is_some_and(|slot| slot.prompt_id() == prompt_id) - { - return; - } - tracing::debug!( - target: "prompt.auto_interject", - "parked marker forgone: interjection continued the parked turn" - ); - self.parked_wait_marker_for = Some(ParkedMarkerSlot::Forgone(prompt_id)); - } - } - /// Visible held rows for the "N queued" hint. 0 outside sendable waits. pub(crate) fn held_queue_count(&self) -> usize { // Goal-gated via `is_parked_on_sendable_wait` (0 during a goal — shell exempts goal turns). @@ -369,20 +209,13 @@ impl AgentView { ); } - /// Whether the stopped-session look is active: the parked-marker slot for - /// the current turn was consumed (marker pushed, or forgone because an - /// interjection continued the parked turn) and the turn is still in its - /// sendable wait. Drives hiding the turn-status row and the idle keybar; - /// flips back off (the running chrome returns) the moment the wait ends - /// and the turn resumes. + /// Whether the stopped-session look is active: the turn is parked in a + /// sendable wait that is not a foreground subagent await. Purely + /// view-derived — no transcript row is written for a park. Drives the + /// idle keybar and the parked turn-status cue; flips back off (the + /// running chrome returns) the moment the wait ends and the turn resumes. pub(crate) fn renders_parked(&self) -> bool { - self.parked_wait_marker_for - .as_ref() - .zip(self.session.current_prompt_id.as_deref()) - .is_some_and(|(slot, pid)| slot.prompt_id() == pid) - && self.is_parked_on_sendable_wait() - // Subagent waits keep running chrome — exclude them from the stopped look. - && !self.is_waiting_on_subagent() + self.is_parked_on_sendable_wait() && !self.is_waiting_on_subagent() } /// Live counts for the turn-status watching cue; see @@ -423,15 +256,6 @@ impl AgentView { stop_hooks: Vec<(String, Vec)>, prompt_id: Option, ) { - // Park → work finished → turn ended with nothing in between: fold the - // completion into the tail parked marker instead of stacking a dup row. - if self.scrollback.fold_completion_into_tail_parked_marker( - &event, - &stop_hooks, - prompt_id.as_deref(), - ) { - return; - } // The marker keeps its turn's pid for the tail-merge attribution check. let block = crate::scrollback::blocks::SessionEventBlock::with_stop_hooks( event, stop_hooks, prompt_id, @@ -678,10 +502,6 @@ impl AgentView { if self.visible_queue_is_empty() { self.hide_queue_pane(); } - // Deleting the last held row can flip the parked - // look on now (the ACP rebroadcast re-checks too, - // but the optimistic remove shouldn't lag). - self.maybe_push_parked_marker(); return InputOutcome::Action(Action::QueueRemoveShared { id: server_id, expected_version: row.version, @@ -691,11 +511,6 @@ impl AgentView { } // No drain kick (cf. mouse [cancel]): queue focus is unreachable mid-edit. self.remove_local_queue_row(id); - // A LOCAL delete has no server rebroadcast to re-evaluate - // the parked look — deleting the last held row must flip - // the stopped chrome on immediately, not on the next - // unrelated notification. - self.maybe_push_parked_marker(); } QueueEvent::EditSelected { id } => { // Entry into editing mode lives in `queue_edit.rs`. diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/render.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/render.rs index 63f8144..100c0ec 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/render.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/render.rs @@ -20,7 +20,7 @@ use crate::theme::Theme; use crate::views::btw_overlay::BTW_OVERLAY_ENTRY_IDX; use crate::views::modal; use crate::views::plan_approval_view::PlanApprovalFocus; -use crate::views::prompt_widget::{PromptFlag, PromptInfo, PromptStyle}; +use crate::views::prompt_widget::{PromptBg, PromptFlag, PromptInfo, PromptStyle}; use crate::views::question_view::QUESTION_VIEW_HPAD; use crate::views::shortcuts_bar::{HintItem, PendingHint, ShortcutsBar}; use crate::views::{agent, turn_status}; @@ -802,7 +802,7 @@ impl AgentView { chrome: true, chrome_pad_left: layout_cfg.block_pad_left, chrome_pad_right: layout_cfg.block_pad_right, - bg_override: None, + bg: PromptBg::Default, accent_color_override: if let Some(c) = self.prompt_input_mode.accent_color(&theme) { Some(c) } else if effective_plan || casual_commenting { @@ -944,7 +944,7 @@ impl AgentView { chrome: false, chrome_pad_left: 0, chrome_pad_right: 0, - bg_override: Some(theme.bg_visual), + bg: PromptBg::Panel(theme.bg_visual), accent_color_override: None, border_color_override: None, prefix_override: None, @@ -979,7 +979,7 @@ impl AgentView { chrome: false, chrome_pad_left: 0, chrome_pad_right: 0, - bg_override: Some(theme.bg_visual), + bg: PromptBg::Panel(theme.bg_visual), accent_color_override: None, border_color_override: None, prefix_override: None, @@ -2334,7 +2334,7 @@ impl AgentView { chrome: false, chrome_pad_left: 0, chrome_pad_right: 0, - bg_override: Some(row_bg), + bg: PromptBg::Panel(row_bg), accent_color_override: None, border_color_override: None, prefix_override: None, diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/session.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/session.rs index 220a605..f21d3f6 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/session.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/session.rs @@ -99,6 +99,7 @@ impl AgentView { session_reload: None, unexpected_replay_drops: 0, replayed_terminal_prompts: HashSet::new(), + failed_wake_marker_for: None, active_pane: ActivePane::Prompt, prompt_mode: PromptMode::Normal, prompt_input_mode: PromptInputMode::Normal, @@ -125,11 +126,11 @@ impl AgentView { cleared_workflow_runs: std::collections::HashSet::new(), show_workflows: false, workflows_view: crate::views::workflows::WorkflowsViewState::default(), - parked_wait_marker_for: None, pending_stop_hooks: None, last_cleared_goal_id: None, show_goal_detail: false, turn_start_ms: None, + turn_start_ms_prompt: None, turn_started_at: None, first_activity_logged_for: None, turn_paused_duration: std::time::Duration::ZERO, diff --git a/crates/codegen/xai-grok-pager/src/app/app_view.rs b/crates/codegen/xai-grok-pager/src/app/app_view.rs index 370130f..aca4db0 100644 --- a/crates/codegen/xai-grok-pager/src/app/app_view.rs +++ b/crates/codegen/xai-grok-pager/src/app/app_view.rs @@ -1229,6 +1229,17 @@ impl AppView { .as_deref() .is_some_and(|r| r.eq_ignore_ascii_case("admin")) } + /// Why `coding_data_sharing` is locked for this user (`None` = editable). + /// Mirrors the dispatch guards in `set_coding_data_sharing`. + pub fn coding_data_sharing_lock(&self) -> Option { + if self.is_zdr { + Some(crate::settings::CodingDataSharingLock::Zdr) + } else if self.is_team_non_admin() { + Some(crate::settings::CodingDataSharingLock::TeamManaged) + } else { + None + } + } /// Welcome privacy banner visibility gates. pub fn privacy_banner_should_show(&self) -> bool { if self.screen_mode.is_minimal() { diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/dashboard.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/dashboard.rs index 947812f..99dce43 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/dashboard.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/dashboard.rs @@ -1286,6 +1286,7 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String) } let coding_data_sharing_opt_out_from_app = app.coding_data_retention_opt_out; + let coding_data_sharing_lock_from_app = app.coding_data_sharing_lock(); let show_tips_from_app = app.show_tips; let auto_update_from_app = app.auto_update; let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds; @@ -1390,6 +1391,7 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String) .map(|(id, info)| (info.name.clone(), id.clone())) .collect(), coding_data_sharing_opt_out: coding_data_sharing_opt_out_from_app, + coding_data_sharing_lock: coding_data_sharing_lock_from_app, plan_mode_active: false, show_tips: show_tips_from_app, auto_update: auto_update_from_app, diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/interject.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/interject.rs index f6eb7fa..ec88084 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/interject.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/interject.rs @@ -53,9 +53,6 @@ pub(super) fn dispatch_interject( agent .scrollback .push_block(RenderBlock::interjection_prompt(&text)); - // Interjecting into a parked wait continues the turn below this block — - // the withheld "Worked for …" marker must not fire late beneath it. - agent.suppress_parked_marker_on_interject(); // The composer is NOT touched here: the producer that consumed composer // text (the InterjectPrompt registry arm) clears it at the call site; @@ -145,7 +142,6 @@ pub(super) fn dispatch_send_prompt_now( // The arm hides the queue echo pushed below — paint the block now. super::queue::push_send_now_user_block(agent, &prompt_id, "prompt", &text, false); } - agent.suppress_parked_marker_on_interject(); let blocks = crate::prompt_images::build_content_blocks_with_workspace( text.clone(), diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/prompt.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/prompt.rs index baad92c..4932364 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/prompt.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/prompt.rs @@ -467,6 +467,7 @@ pub(super) fn dispatch_send_prompt_inner( }; // Capture app-level fields before the mut-borrow on `agent`. let coding_data_sharing_opt_out_from_app = app.coding_data_retention_opt_out; + let coding_data_sharing_lock_from_app = app.coding_data_sharing_lock(); let show_tips_from_app = app.show_tips; let auto_update_from_app = app.auto_update; let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds; @@ -563,6 +564,7 @@ pub(super) fn dispatch_send_prompt_inner( .map(|(id, info)| (info.name.clone(), id.clone())) .collect(), coding_data_sharing_opt_out: coding_data_sharing_opt_out_from_app, + coding_data_sharing_lock: coding_data_sharing_lock_from_app, // Prefer optimistic pending over confirmed active. plan_mode_active: agent.plan_mode_pending.unwrap_or(agent.plan_mode_active), show_tips: show_tips_from_app, @@ -845,7 +847,6 @@ pub(super) fn dispatch_send_prompt_inner( if parked_sendable_wait && !hold_behind_existing_queue { agent.arm_send_now_expectation(prompt_id.clone()); - agent.suppress_parked_marker_on_interject(); } if consume_input { diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/queue.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/queue.rs index 1a5f98c..bf0bc9b 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/queue.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/queue.rs @@ -1016,7 +1016,7 @@ mod tests { use crate::app::actions::Action; use crate::app::agent::AgentState; use crate::app::agent_view::test_fixtures::{ - complete_task_output_wait_call, count_parked, running_subagent_info, + complete_task_output_wait_call, count_turn_markers, running_subagent_info, simulate_subagent_wait, simulate_task_output_wait, simulate_task_output_wait_call, }; use crate::app::dispatch::router::dispatch; @@ -2299,64 +2299,59 @@ mod tests { } #[test] - fn parked_marker_fires_once_on_empty_queue_park() { + fn parked_wait_renders_parked_without_markers() { let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1"); let agent = app.agents.get_mut(&id).unwrap(); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 1); - assert!(agent.renders_parked(), "marker + live wait = parked look"); - - // Idempotent within the same park. - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 1, "one marker per park"); + assert!(agent.renders_parked(), "live wait = parked look"); + assert_eq!(count_turn_markers(agent), 0, "a park writes no marker"); } - /// A re-park after new PARENT OUTPUT (streamed through the tracker, so - /// the agent-output epoch bumps) pushes a fresh marker for the new park - /// episode — otherwise the second park renders as a dead session. #[test] - fn parked_marker_repushes_on_repark_after_new_parent_output() { + fn sibling_batch_park_writes_no_markers() { use crate::acp::meta::NotificationMeta; + use std::sync::Arc; let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); let agent = app.agents.get_mut(&id).unwrap(); - simulate_task_output_wait_call(agent, "wait-1", "bg-1", 30_000); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 1); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 1, "same episode must dedupe"); + // Blocking get_task_output registers first; anchor still running. + simulate_task_output_wait_call(agent, "wait-1", "bg-anchor", 120_000); + agent + .session + .bg_tasks + .insert("bg-anchor".into(), running_bg_task("bg-anchor")); + assert_eq!(count_turn_markers(agent), 0); - complete_task_output_wait_call(agent, "wait-1"); - assert!(!agent.renders_parked(), "no parked look between parks"); - // Between-parks content streams through the tracker (the production - // path), bumping the agent-output epoch. - assert!(agent.session.tracker.handle_update( - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text( - acp::TextContent::new("between-parks content") - ),)), - &NotificationMeta::default(), - &mut agent.scrollback, - )); - - simulate_task_output_wait_call(agent, "wait-2", "bg-1", 600_000); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 2, "new episode pushes a fresh marker"); - assert!(agent.renders_parked()); + for i in 2..=5 { + let tc_id = format!("wait-batch-tc{i}"); + agent.session.handle_update( + acp::SessionUpdate::ToolCall( + acp::ToolCall::new( + acp::ToolCallId::new(Arc::from(tc_id.as_str())), + "run_terminal_command", + ) + .kind(acp::ToolKind::Execute) + .status(acp::ToolCallStatus::Pending), + ), + &NotificationMeta::default(), + &mut agent.scrollback, + ); + } + assert_eq!( + count_turn_markers(agent), + 0, + "a sibling-batch park must write zero markers" + ); } - /// Rows landing during a park WITHOUT parent output (chips and other - /// direct scrollback pushes) stay in the same park episode — the marker - /// is never re-pushed under them; the "… still running" status row carries - /// the ongoing-work story instead. #[test] - fn parked_marker_stays_single_when_rows_land_mid_park() { + fn chips_and_completions_mid_park_add_no_markers() { use crate::scrollback::block::RenderBlock; let mut app = test_app_with_agent(); @@ -2365,170 +2360,69 @@ mod tests { let agent = app.agents.get_mut(&id).unwrap(); simulate_task_output_wait_call(agent, "wait-1", "bg-1", 30_000); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 1); + assert_eq!(count_turn_markers(agent), 0); agent.scrollback.push_block(RenderBlock::bg_task_completed( "sleep 5", "bg-2", std::time::Duration::from_secs(5), )); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 1, "chips never re-push the marker"); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 1, "still the same park episode"); + assert_eq!(count_turn_markers(agent), 0, "chips add no markers"); + assert!(agent.renders_parked(), "chips keep the parked look"); } - /// A re-park whose previous marker is still the transcript tail pushes - /// nothing (poll loop: wait expiry → immediate re-issue). #[test] - fn parked_marker_not_repushed_when_marker_still_tail() { + fn wait_completion_clears_parked_look() { let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); let agent = app.agents.get_mut(&id).unwrap(); simulate_task_output_wait_call(agent, "wait-1", "bg-1", 15_000); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 1); + assert!(agent.renders_parked()); - // Wait tools render no blocks, so the marker stays the tail. complete_task_output_wait_call(agent, "wait-1"); + assert!(!agent.renders_parked(), "no parked look between parks"); + assert_eq!(count_turn_markers(agent), 0); + simulate_task_output_wait_call(agent, "wait-2", "bg-1", 15_000); - agent.maybe_push_parked_marker(); + assert!(agent.renders_parked(), "a re-park flips the look back on"); + assert_eq!(count_turn_markers(agent), 0, "re-parks stay markerless"); + } + + #[test] + fn interjection_during_park_adds_no_marker() { + use crate::scrollback::block::RenderBlock; + + let mut app = test_app_with_agent(); + let id = AgentId(0); + dispatch(Action::SendPrompt("first".into()), &mut app); + simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1"); + assert!(app.agents[&id].renders_parked()); + + let _ = dispatch( + Action::Interject { + text: "hurry up".into(), + images: Vec::new(), + }, + &mut app, + ); + + let agent = &app.agents[&id]; assert_eq!( - count_parked(agent), - 1, - "marker still at the tail: a re-push would be a duplicate line" + count_turn_markers(agent), + 0, + "no marker around the interjection" ); assert!( - agent.renders_parked(), - "the park itself still renders parked" + matches!( + agent.scrollback.last().map(|e| &e.block), + Some(RenderBlock::UserPrompt(_)) + ), + "the user prompt row lands with nothing under it" ); } - /// An interjection below an already-pushed marker must not trigger a - /// restate beneath the user's message (the queue-emptying re-evaluation - /// fires before the wait-abort lands). - #[test] - fn rendered_slot_stays_quiet_under_tail_interjection() { - use crate::scrollback::block::RenderBlock; - - let mut app = test_app_with_agent(); - let id = AgentId(0); - dispatch(Action::SendPrompt("first".into()), &mut app); - let agent = app.agents.get_mut(&id).unwrap(); - - simulate_task_output_wait_call(agent, "wait-1", "bg-1", 30_000); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 1); - - agent - .scrollback - .push_block(RenderBlock::interjection_prompt("hurry up")); - agent.suppress_parked_marker_on_interject(); - agent.maybe_push_parked_marker(); - assert_eq!( - count_parked(agent), - 1, - "no marker may render beneath the interjected message" - ); - } - - /// `Forgone` is final for the turn: even a genuine re-park with buried - /// content must not resurrect the marker. - #[test] - fn forgone_slot_blocks_repark_repush() { - use crate::scrollback::block::RenderBlock; - - let mut app = test_app_with_agent(); - let id = AgentId(0); - dispatch(Action::SendPrompt("first".into()), &mut app); - let agent = app.agents.get_mut(&id).unwrap(); - - simulate_task_output_wait_call(agent, "wait-1", "bg-1", 30_000); - agent.suppress_parked_marker_on_interject(); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 0, "forgone park renders no marker"); - - complete_task_output_wait_call(agent, "wait-1"); - agent - .scrollback - .push_block(RenderBlock::agent_message("continued below interject")); - simulate_task_output_wait_call(agent, "wait-2", "bg-1", 30_000); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 0, "forgone stays silent all turn"); - } - - /// A work-count change never touches the marker — the counts live on the - /// status row's "… still running" cue, so the transcript stays quiet while - /// work finishes mid-park. - #[test] - fn count_change_never_restates_marker() { - let mut app = test_app_with_agent(); - let id = AgentId(0); - dispatch(Action::SendPrompt("first".into()), &mut app); - let agent = app.agents.get_mut(&id).unwrap(); - agent - .session - .bg_tasks - .insert("bg-1".into(), running_bg_task("bg-1")); - agent - .session - .bg_tasks - .insert("bg-2".into(), running_bg_task("bg-2")); - - simulate_task_output_wait_call(agent, "wait-1", "bg-1", 30_000); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 1); - assert_eq!(agent.watchers().commands, 2); - - agent.session.bg_tasks.remove("bg-2"); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 1, "count changes never restate"); - assert_eq!(agent.watchers().commands, 1, "the cue counts down instead"); - } - - #[test] - fn parked_marker_not_pushed_while_send_now_echo_is_only_row() { - let mut app = test_app_with_agent(); - let id = AgentId(0); - dispatch(Action::SendPrompt("first".into()), &mut app); - enqueue_local(&mut app, id, "held then send-now'd"); - simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1"); - - let agent = app.agents.get_mut(&id).unwrap(); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 0, "held row withholds the marker"); - - agent.session.pending_prompts.clear(); - agent.expect_send_now_cancel = Some("send-now-echo".into()); - agent.shared_queue = vec![crate::app::prompt_queue::QueueEntryWire { - id: "send-now-echo".into(), - version: 0, - owner: None, - last_editor: None, - kind: "prompt".into(), - text: "send now payload".into(), - position: 0, - combined_texts: None, - }]; - assert!(agent.visible_queue_is_empty()); - assert!(agent.has_held_user_queue()); - - agent.maybe_push_parked_marker(); - assert_eq!( - count_parked(agent), - 0, - "send-now occupancy must block the parked marker" - ); - - agent.suppress_parked_marker_on_interject(); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 0, "forgone slot stays silent"); - } - - /// Queued rows HOLD during a parked/blocking wait; nothing drains on its own. #[test] fn parked_wait_holds_queue_and_explains_itself() { let mut app = test_app_with_agent(); @@ -2538,9 +2432,11 @@ mod tests { simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1"); let agent = app.agents.get_mut(&id).unwrap(); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 0, "queued row must hold the marker"); - assert!(!agent.renders_parked()); + assert_eq!(count_turn_markers(agent), 0); + assert!( + agent.renders_parked(), + "the parked look is queue-occupancy-independent" + ); assert_eq!( agent.held_queue_count(), 1, @@ -2565,12 +2461,7 @@ mod tests { 1, "held row feeds the inline status hint" ); - agent.maybe_push_parked_marker(); - assert_eq!( - count_parked(agent), - 0, - "queued row holds the (excluded) marker" - ); + assert_eq!(count_turn_markers(agent), 0); assert!(!agent.renders_parked()); // Even with an empty queue + live subagent, a subagent wait never parks. @@ -2578,18 +2469,19 @@ mod tests { .subagent_sessions .insert("child-1".into(), running_subagent_info("child-1")); agent.session.pending_prompts.clear(); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 0, "subagent wait must never park"); + assert_eq!( + count_turn_markers(agent), + 0, + "subagent wait must never park" + ); assert!( !agent.renders_parked(), "subagent wait keeps running chrome" ); } - /// T1 regression: once the model resumes streaming in the SAME turn, the - /// parked/stopped look must flip off (the running chrome returns) even if - /// the wait tool's terminal ToolCallUpdate never reached this client — - /// a live chunk proves the turn is no longer parked in the wait. + /// T1 regression: a live chunk must un-park even when the wait's terminal + /// ToolCallUpdate never reached this client. #[test] fn parked_look_clears_when_model_resumes_streaming() { let mut app = test_app_with_agent(); @@ -2598,7 +2490,6 @@ mod tests { simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1"); let agent = app.agents.get_mut(&id).unwrap(); - agent.maybe_push_parked_marker(); assert!(agent.renders_parked(), "parked look active during the wait"); // The model resumes with a message chunk (no Completed for the wait). @@ -2624,8 +2515,7 @@ mod tests { "the stale wait must not survive a resumed stream" ); - // A new-stream thought also un-parks; same-stream thoughts must not. - // Establish the wait under stream_start=1, then thought under 9001. + // A new-stream thought (different stream_start_ms) also un-parks; same-stream must not. { let wait_meta = crate::acp::meta::NotificationMeta { stream_start_ms: Some(1), @@ -2679,9 +2569,8 @@ mod tests { ); } - /// T4 regression: the inline hint only advertises "Enter to send now" - /// when the TOP held row would actually send (server rows always; local - /// rows only when prompt-like — bash rows refuse with a toast). + /// T4 regression: the hint must only advertise "Enter to send now" when + /// the TOP held row would actually send (a bash top row no-ops). #[test] fn held_hint_advertises_send_now_only_for_sendable_top() { let mut app = test_app_with_agent(); @@ -2689,7 +2578,6 @@ mod tests { dispatch(Action::SendPrompt("first".into()), &mut app); simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1"); - // Local bash row on top: counted, but Enter would no-op. let agent = app.agents.get_mut(&id).unwrap(); agent.session.enqueue_bash_command("git status".into()); assert_eq!(agent.held_queue_count(), 1); @@ -2698,7 +2586,6 @@ mod tests { "a bash top row must not advertise Enter-send-now" ); - // A plain local prompt on top instead: sendable. agent.session.pending_prompts.clear(); agent.session.enqueue_prompt("plain follow-up".into()); assert!(agent.held_queue_top_sendable()); @@ -2846,10 +2733,8 @@ mod tests { assert_eq!(agent.held_queue_count(), 0); } - /// The armed send-now cancel does NOT count as held occupancy once it is - /// the running turn (arm id == current_prompt_id) — otherwise the parked - /// marker is suppressed and a new prompt is wrongly held behind an empty - /// queue after a send-now adopts. + /// An arm that became the running turn is not held occupancy — otherwise a + /// new prompt is wrongly held behind an empty queue after a send-now adopts. #[test] fn has_held_user_queue_excludes_arm_that_is_running() { let mut app = test_app_with_agent(); @@ -2858,7 +2743,6 @@ mod tests { agent.session.pending_prompts.clear(); agent.shared_queue.clear(); - // Matching send-now adopt: the armed id became the running turn. agent.expect_send_now_cancel = Some("p-run".into()); agent.session.current_prompt_id = Some("p-run".into()); assert!( @@ -2866,7 +2750,6 @@ mod tests { "an arm for the running turn is not held occupancy" ); - // A stale arm for a different (not-running) prompt still occupies hold. agent.session.current_prompt_id = Some("p-other".into()); assert!( agent.has_held_user_queue(), @@ -2874,10 +2757,8 @@ mod tests { ); } - /// T2 regression: deleting the LAST held local row re-evaluates the - /// parked look immediately (no waiting for an unrelated notification). #[test] - fn local_delete_of_last_held_row_flips_parked_look_on() { + fn local_delete_of_last_held_row_adds_no_marker() { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let mut app = test_app_with_agent(); @@ -2887,9 +2768,11 @@ mod tests { simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1"); let agent = app.agents.get_mut(&id).unwrap(); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 0, "held row holds the marker"); - assert!(!agent.renders_parked()); + assert_eq!(count_turn_markers(agent), 0); + assert!( + agent.renders_parked(), + "parked look on even with a held row" + ); // Delete the row through the queue-pane key path. agent.queue.sync_from_merged( @@ -2910,14 +2793,11 @@ mod tests { assert!(agent.session.pending_prompts.is_empty()); assert_eq!( - count_parked(agent), - 1, - "deleting the last held row must push the parked marker now" - ); - assert!( - agent.renders_parked(), - "the stopped look must flip on immediately after the local delete" + count_turn_markers(agent), + 0, + "deleting the last held row must not write a marker" ); + assert!(agent.renders_parked(), "the stopped look stays on"); } /// T3 regression: a task-tool refinement that OMITS `run_in_background` @@ -2985,15 +2865,14 @@ mod tests { ); } - /// A plain mid-turn interjection (no wait) must NOT consume the marker - /// slot: a later park in the same turn still deserves its marker. + /// A plain mid-turn interjection needs no suppression state for a later + /// park in the same turn. #[test] - fn non_parked_interjection_keeps_later_park_marker() { + fn interjection_then_later_park_still_renders_parked() { let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); - // Mid-turn interjection while streaming (no wait advertised). let _ = dispatch( Action::Interject { text: "heads up".into(), @@ -3002,37 +2881,32 @@ mod tests { &mut app, ); assert!( - app.agents[&id].parked_wait_marker_for.is_none(), - "no wait → slot must stay free" + !app.agents[&id].renders_parked(), + "no wait → no parked look" ); - // The turn later parks: the marker still fires. simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1"); let agent = app.agents.get_mut(&id).unwrap(); - agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 1, "later park keeps its marker"); + assert!(agent.renders_parked(), "later park renders parked"); + assert_eq!(count_turn_markers(agent), 0, "and stays markerless"); } /// Parked chrome must clear OSC 9;4 (and treat the tab title as idle) so /// Ghostty/WezTerm drop the progress bar while the session looks stopped. - /// The turn is still `TurnRunning` server-side — only `renders_parked` - /// flips the notification busy bit. #[test] fn parked_wait_clears_progress_bar_notification() { let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); - simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1"); - // Running wait, no parked marker yet → still busy chrome / progress on. app.update_notifications(); assert!( app.notification_service.is_progress_active(), "live turn must keep the OSC 9;4 progress indicator active" ); + simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1"); let agent = app.agents.get_mut(&id).unwrap(); - agent.maybe_push_parked_marker(); assert!(agent.renders_parked()); assert!( agent.session.state.is_busy(), @@ -3046,46 +2920,4 @@ mod tests { "parked look must clear OSC 9;4 so the terminal progress bar stops" ); } - - /// The parked push is the unified marker: a static `TurnCompleted` event - /// block flagged `parked`, stamped with the turn's pid. It carries no - /// work counts — the persistent "… still running" status row above the - /// prompt tracks the still-running work. The real final marker later - /// pushes separately (two static lines — main's park shape). - #[test] - fn parked_marker_is_static_completed_snapshot() { - use crate::scrollback::block::RenderBlock; - use crate::scrollback::blocks::SessionEvent; - - let mut app = test_app_with_agent(); - let id = AgentId(0); - dispatch(Action::SendPrompt("first".into()), &mut app); - let agent = app.agents.get_mut(&id).unwrap(); - agent - .session - .bg_tasks - .insert("bg-1".into(), running_bg_task("bg-1")); - simulate_task_output_wait(agent, "bg-1"); - agent.maybe_push_parked_marker(); - - let block = (0..agent.scrollback.len()) - .rev() - .find_map(|i| match agent.scrollback.get(i).map(|e| &e.block) { - Some(RenderBlock::SessionEvent(b)) => Some(b), - _ => None, - }) - .expect("the park must push a marker block"); - assert!(matches!(block.event, SessionEvent::TurnCompleted { .. })); - assert!(block.parked); - assert_eq!( - block.prompt_id, agent.session.current_prompt_id, - "the park stamps the marker with its turn's pid" - ); - assert!( - block.stop_hooks.is_empty(), - "a parked marker carries no hooks" - ); - // The running bg command shows in the watchers cue, not the marker. - assert_eq!(agent.watchers().commands, 1); - } } diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/settings/ui.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/settings/ui.rs index 78e6aef..4e5a49f 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/settings/ui.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/settings/ui.rs @@ -47,6 +47,7 @@ pub(crate) fn refresh_open_settings_modals(app: &mut AppView) { let ui_snapshot = app.current_ui.clone(); // Capture app-level fields before the mut-borrow loop. let coding_data_sharing_opt_out_from_app = app.coding_data_retention_opt_out; + let coding_data_sharing_lock_from_app = app.coding_data_sharing_lock(); let show_tips_from_app = app.show_tips; let auto_update_from_app = app.auto_update; let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds; @@ -80,6 +81,7 @@ pub(crate) fn refresh_open_settings_modals(app: &mut AppView) { .map(|(id, info)| (info.name.clone(), id.clone())) .collect(), coding_data_sharing_opt_out: coding_data_sharing_opt_out_from_app, + coding_data_sharing_lock: coding_data_sharing_lock_from_app, // Prefer optimistic pending over confirmed active. plan_mode_active: agent.plan_mode_pending.unwrap_or(agent.plan_mode_active), show_tips: show_tips_from_app, @@ -182,6 +184,7 @@ pub(in crate::app::dispatch) fn dispatch_open_settings( let ui_snapshot = app.current_ui.clone(); // Capture app-level fields before the mut-borrow on the agent. let coding_data_sharing_opt_out_from_app = app.coding_data_retention_opt_out; + let coding_data_sharing_lock_from_app = app.coding_data_sharing_lock(); let show_tips_from_app = app.show_tips; let auto_update_from_app = app.auto_update; let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds; @@ -224,6 +227,7 @@ pub(in crate::app::dispatch) fn dispatch_open_settings( .map(|(id, info)| (info.name.clone(), id.clone())) .collect(), coding_data_sharing_opt_out: coding_data_sharing_opt_out_from_app, + coding_data_sharing_lock: coding_data_sharing_lock_from_app, // Prefer optimistic pending over confirmed active. plan_mode_active: agent.plan_mode_pending.unwrap_or(agent.plan_mode_active), show_tips: show_tips_from_app, @@ -702,6 +706,7 @@ pub(crate) fn build_pager_snapshot(app: &AppView) -> crate::settings::PagerLocal current_model_name: agent_current_model_name(app), available_models: agent_available_models(app), coding_data_sharing_opt_out: app.coding_data_retention_opt_out, + coding_data_sharing_lock: app.coding_data_sharing_lock(), plan_mode_active: agent_plan_mode(app), show_tips: app.show_tips, auto_update: app.auto_update, diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/prompt.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/prompt.rs index 22876ab..7802836 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/prompt.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/prompt.rs @@ -3700,17 +3700,21 @@ fn interactive_cancel_supersedes_send_now_expectation() { ); } -/// The parked "Worked for" marker stays the only marker across a send-now cancel. +/// A send-now cancel out of a park leaves no markers at all: the park is +/// markerless and the armed expectation suppresses the cancel marker. #[test] -fn send_now_cancel_after_park_leaves_single_parked_marker() { - use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait}; +fn send_now_cancel_after_park_leaves_no_markers() { + use crate::app::agent_view::test_fixtures::{count_turn_markers, simulate_task_output_wait}; let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1"); - app.agents.get_mut(&id).unwrap().maybe_push_parked_marker(); - assert_eq!(count_parked(&app.agents[&id]), 1); + assert_eq!( + count_turn_markers(&app.agents[&id]), + 0, + "a park writes no marker" + ); // Typing into the parked wait: plain send arms the expectation; cancel arrives meta-less. let _ = dispatch(Action::SendPrompt("next thing".into()), &mut app); @@ -3719,14 +3723,8 @@ fn send_now_cancel_after_park_leaves_single_parked_marker() { assert_eq!(count_cancelled_markers(&app, id), 0); assert_eq!( count_completed_markers(&app, id), - 1, - "the parked marker stays the only completed line (no duplicate)" - ); - app.agents.get_mut(&id).unwrap().maybe_push_parked_marker(); - assert_eq!( - count_parked(&app.agents[&id]), - 1, - "no late parked marker after the send-now cancel" + 0, + "no completed marker renders for the cancelled parked turn" ); } diff --git a/crates/codegen/xai-grok-pager/src/app/mod.rs b/crates/codegen/xai-grok-pager/src/app/mod.rs index 18d70c9..853a6d9 100644 --- a/crates/codegen/xai-grok-pager/src/app/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/mod.rs @@ -641,7 +641,7 @@ pub async fn run( default_yolo_mode: launch_yolo.yolo, default_auto_mode: launch_auto && !launch_yolo.yolo, }; - let connection = if use_leader { + let mut connection = if use_leader { let conn = crate::acp::connect_via_leader(&cancel, connect_flags, &raw_config).await?; tracing::info!( elapsed_ms = startup_start.elapsed().as_millis() as u64, @@ -656,6 +656,8 @@ pub async fn run( ); conn }; + let agent_guard = + crate::acp::spawn::AgentShutdownGuard::new(cancel.clone(), connection.agent_thread.take()); let mut config_watcher = crate::appearance::ConfigWatcher::start().await?; let alt_screen_config_mode = config_watcher.current().alt_screen; let term_ctx = crate::terminal::terminal_context(); @@ -756,7 +758,7 @@ pub async fn run( .await; crate::unified_log::flush_blocking().await; let restore_result = restore_terminal(terminal, writer_thread, screen_mode); - cancel.cancel(); + drop(agent_guard); xai_tty_utils::global_process_scope().kill_all(); if let Err(cleanup_error) = restore_result { match &result { diff --git a/crates/codegen/xai-grok-pager/src/app/mouse.rs b/crates/codegen/xai-grok-pager/src/app/mouse.rs index bd81ced..6f3bb73 100644 --- a/crates/codegen/xai-grok-pager/src/app/mouse.rs +++ b/crates/codegen/xai-grok-pager/src/app/mouse.rs @@ -19,6 +19,17 @@ use crate::views::prompt_widget::PromptEvent; use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use std::time::Instant; impl AgentView { + /// Time-paired multi-click check for the prompt textarea. Pairing is + /// time-only (no coordinates); a mispaired action is one undo step. + /// Records the click for the next pairing. + pub(super) fn prompt_click_is_double(&mut self) -> bool { + let now = std::time::Instant::now(); + let is_double = self + .last_prompt_click_ms + .is_some_and(|last| now.duration_since(last).as_millis() < MULTI_CLICK_TIMEOUT_MS); + self.last_prompt_click_ms = Some(now); + is_double + } /// Handle mouse events: click-to-focus, forward to prompt textarea. /// /// Scroll events are handled at app level (not here). @@ -454,7 +465,6 @@ impl AgentView { if self.visible_queue_is_empty() { self.hide_queue_pane(); } - self.maybe_push_parked_marker(); return InputOutcome::Action(Action::QueueRemoveShared { id: server_id, expected_version: row.version, @@ -464,7 +474,6 @@ impl AgentView { } let was_drain_blocked = self.drain_blocked(); self.remove_local_queue_row(id); - self.maybe_push_parked_marker(); if was_drain_blocked { return InputOutcome::Action(Action::DrainQueue); } @@ -509,10 +518,7 @@ impl AgentView { self.pending_effects.push(eff); } } - let now = std::time::Instant::now(); - if let Some(last) = self.last_prompt_click_ms - && now.duration_since(last).as_millis() < MULTI_CLICK_TIMEOUT_MS - { + if self.prompt_click_is_double() { if self.prompt.file_ref_near_cursor() && let Some((path, initial_range)) = self.prompt.file_ref_element_at_cursor() @@ -526,7 +532,6 @@ impl AgentView { self.prompt.refresh_slash(&self.session.models); } } - self.last_prompt_click_ms = Some(now); } InputOutcome::Changed } diff --git a/crates/codegen/xai-grok-pager/src/app/turn_completion.rs b/crates/codegen/xai-grok-pager/src/app/turn_completion.rs index ae4085f..8143d90 100644 --- a/crates/codegen/xai-grok-pager/src/app/turn_completion.rs +++ b/crates/codegen/xai-grok-pager/src/app/turn_completion.rs @@ -19,8 +19,9 @@ use super::app_view::AppView; /// (right-justified) on the marker line instead of as a standalone block. /// /// All three marker rails route through here: the driver's `PromptResponse`, -/// the lost-RPC reconcile, and the viewer finalize. (Wake turns close -/// markerless — see `finish_wake_turn` in acp_handler.) `event == None` +/// the lost-RPC reconcile, and the viewer finalize. (Wake turns route through +/// `finish_wake_turn` in acp_handler, which maps their stop reason and calls +/// here only when a marker is due.) `event == None` /// (bash turns, rate-limit / re-auth UX that replaces the marker) flushes the /// held hooks as the legacy standalone lifecycle block so failures stay /// visible. diff --git a/crates/codegen/xai-grok-pager/src/app/turn_completion/tests.rs b/crates/codegen/xai-grok-pager/src/app/turn_completion/tests.rs index fd706b1..3e42823 100644 --- a/crates/codegen/xai-grok-pager/src/app/turn_completion/tests.rs +++ b/crates/codegen/xai-grok-pager/src/app/turn_completion/tests.rs @@ -418,9 +418,6 @@ fn last_marker_block(agent: &AgentView) -> &SessionEventBlock { #[test] fn real_end_marker_stays_plain_with_running_work() { - // Background work never rides the end marker as a "still running" suffix - // — the persistent "… still running" status row carries it instead. The - // running command shows up in the watchers count only. let mut agent = running_driver("p1"); insert_bg_task(&mut agent, "bg-1", false); @@ -433,7 +430,6 @@ fn real_end_marker_stays_plain_with_running_work() { ); let block = last_marker_block(&agent); - assert!(!block.parked); assert_eq!(block.prompt_id.as_deref(), Some("p1")); assert_eq!(block.event.message(), "Worked for 2.0s"); assert_eq!( @@ -533,108 +529,15 @@ fn driver_arm_records_cancel_trigger_for_reconcile() { ); } -/// All `TurnCompleted` markers (parked and final) in scrollback order. -fn completed_markers(sb: &ScrollbackState) -> Vec { - (0..sb.len()) - .filter_map(|i| match sb.get(i).map(|e| &e.block) { - Some(RenderBlock::SessionEvent(b)) - if matches!(b.event, SessionEvent::TurnCompleted { .. }) => - { - Some(b.clone()) - } - _ => None, - }) - .collect() -} - -/// The tail parked marker `maybe_push_parked_marker` leaves mid-turn (same -/// shape as `push_parked_marker_block`). -fn push_parked_tail(agent: &mut AgentView, prompt_id: &str, secs: u64) { - let mut parked = SessionEventBlock::new(SessionEvent::TurnCompleted { - elapsed: Some(std::time::Duration::from_secs(secs)), - }); - parked.parked = true; - parked.prompt_id = Some(prompt_id.into()); - agent - .scrollback - .push_block(RenderBlock::SessionEvent(parked)); -} - +/// The turn-end marker takes no fold path — a park has no row to fold into. #[test] -fn completion_folds_tail_parked_marker_instead_of_duplicating() { - // Park → work finished → turn ended with nothing in between: the - // completion folds into the parked marker, not a second identical row. - let mut agent = running_driver("p1"); - push_parked_tail(&mut agent, "p1", 3); +fn turn_end_after_park_pushes_single_marker() { + use crate::app::agent_view::test_fixtures::count_turn_markers; - push_turn_terminal_marker( - &mut agent, - Some(SessionEvent::TurnCompleted { - elapsed: Some(std::time::Duration::from_secs(5)), - }), - Some("p1"), - ); - - let markers = completed_markers(&agent.scrollback); - assert_eq!( - markers.len(), - 1, - "park + completion must render ONE marker, got {}", - markers.len() - ); - assert!(!markers[0].parked, "the folded marker is the real turn end"); - assert_eq!( - markers[0].event.message(), - "Worked for 5.0s", - "the folded marker carries the final elapsed" - ); -} - -#[test] -fn completion_fold_attaches_stop_hooks_to_folded_marker() { - let mut agent = running_driver("p1"); - push_parked_tail(&mut agent, "p1", 3); - agent.pending_stop_hooks = Some(super::super::agent_view::PendingStopHooks { - prompt_id: Some("p1".into()), - groups: one_stop_group(), - }); - - push_turn_terminal_marker( - &mut agent, - Some(SessionEvent::TurnCompleted { - elapsed: Some(std::time::Duration::from_secs(5)), - }), - Some("p1"), - ); - - let markers = completed_markers(&agent.scrollback); - assert_eq!(markers.len(), 1); - assert_eq!( - markers[0].stop_hooks.len(), - 1, - "stop hooks must ride the folded marker" - ); - // A hook-carrying marker rests Collapsed on the fresh-push and - // attach_stop_hooks paths; the fold must match. - let folded = agent.scrollback.last().expect("folded marker entry"); - assert_eq!( - folded.display_mode, - crate::scrollback::types::DisplayMode::Collapsed, - "folded marker with stop hooks must rest collapsed" - ); -} - -#[test] -fn completion_folds_marker_pushed_by_real_park_path() { - // Drive the real park — blocking wait through the tracker, then - // `maybe_push_parked_marker` — so the fold's keys (parked + prompt_id) - // stay pinned to the production marker shape, not the test helper's. let mut agent = running_driver("p1"); super::super::agent_view::test_fixtures::simulate_task_output_wait(&mut agent, "bg-1"); - agent.maybe_push_parked_marker(); - let parked = completed_markers(&agent.scrollback); - assert_eq!(parked.len(), 1, "real park path must push one marker"); - assert!(parked[0].parked); + assert!(agent.renders_parked()); + assert_eq!(count_turn_markers(&agent), 0, "the park writes no marker"); push_turn_terminal_marker( &mut agent, @@ -644,140 +547,10 @@ fn completion_folds_marker_pushed_by_real_park_path() { Some("p1"), ); - let markers = completed_markers(&agent.scrollback); assert_eq!( - markers.len(), + count_turn_markers(&agent), 1, - "completion must fold into the marker the real park path pushed" - ); - assert!(!markers[0].parked); -} - -#[test] -fn completion_does_not_fold_across_bg_completion_chip() { - // Park → bg task completes (chip lands under the marker) → turn ends: - // folding would teleport the boundary above the chip, so this flow - // intentionally keeps both markers. - let mut agent = running_driver("p1"); - push_parked_tail(&mut agent, "p1", 3); - agent.scrollback.push_block(RenderBlock::bg_task_completed( - "sleep 5", - "task-1", - std::time::Duration::from_secs(5), - )); - - push_turn_terminal_marker( - &mut agent, - Some(SessionEvent::TurnCompleted { - elapsed: Some(std::time::Duration::from_secs(9)), - }), - Some("p1"), - ); - - let markers = completed_markers(&agent.scrollback); - assert_eq!( - markers.len(), - 2, - "a chip between park and completion keeps both markers" - ); -} - -#[test] -fn completion_does_not_fold_committed_parked_marker() { - // Minimal mode already printed the parked row (print-once): an in-place - // fold would never reach the terminal — a fresh marker must be pushed. - let mut agent = running_driver("p1"); - push_parked_tail(&mut agent, "p1", 3); - let parked_idx = agent.scrollback.len() - 1; - agent.scrollback.mark_committed(parked_idx); - agent.scrollback.set_commit_scan_cursor(parked_idx + 1); - - push_turn_terminal_marker( - &mut agent, - Some(SessionEvent::TurnCompleted { - elapsed: Some(std::time::Duration::from_secs(5)), - }), - Some("p1"), - ); - - let markers = completed_markers(&agent.scrollback); - assert_eq!( - markers.len(), - 2, - "committed tail must not fold: the completion appends a fresh marker" - ); - assert!(markers[0].parked, "the committed parked row is untouched"); - assert!(!markers[1].parked, "the fresh marker is the real turn end"); - let fresh = agent.scrollback.last().expect("fresh marker entry"); - assert!( - !agent.scrollback.is_committed(fresh.id), - "fresh marker is uncommitted so the commit pass will print it" - ); -} - -#[test] -fn completion_does_not_fold_foreign_or_buried_parked_markers() { - // Different prompt id at the tail: not this turn's park — push normally. - let mut agent = running_driver("p2"); - push_parked_tail(&mut agent, "p1", 3); - push_turn_terminal_marker( - &mut agent, - Some(SessionEvent::TurnCompleted { - elapsed: Some(std::time::Duration::from_secs(5)), - }), - Some("p2"), - ); - assert_eq!( - completed_markers(&agent.scrollback).len(), - 2, - "a foreign parked marker must not swallow another turn's completion" - ); - - // Buried park (agent output rendered after it): the park no longer - // explains the tail — the completion pushes its own marker. - let mut agent = running_driver("p1"); - push_parked_tail(&mut agent, "p1", 3); - agent.scrollback.push_block(RenderBlock::System( - crate::scrollback::blocks::SystemMessageBlock::new("resumed"), - )); - push_turn_terminal_marker( - &mut agent, - Some(SessionEvent::TurnCompleted { - elapsed: Some(std::time::Duration::from_secs(5)), - }), - Some("p1"), - ); - assert_eq!( - completed_markers(&agent.scrollback).len(), - 2, - "a buried parked marker must not fold" - ); -} - -#[test] -fn failure_never_folds_into_a_parked_marker() { - // A parked "Worked for" is a completion-shaped boundary; a failure is a - // different outcome and must render as its own row beneath it. - let mut agent = running_driver("p1"); - push_parked_tail(&mut agent, "p1", 3); - push_turn_terminal_marker( - &mut agent, - Some(SessionEvent::TurnFailed { - error: "boom".into(), - elapsed: Some(std::time::Duration::from_secs(5)), - }), - Some("p1"), - ); - assert_eq!( - completed_markers(&agent.scrollback).len(), - 1, - "the parked marker stays" - ); - assert!( - matches!( - last_session_event(&agent.scrollback), - Some(SessionEvent::TurnFailed { .. }) - ), - "the failure renders as its own row" + "the real turn end pushes exactly one marker" ); + assert_eq!(last_marker_block(&agent).event.message(), "Worked for 5.0s"); } diff --git a/crates/codegen/xai-grok-pager/src/headless.rs b/crates/codegen/xai-grok-pager/src/headless.rs index 12514da..2f3e46f 100644 --- a/crates/codegen/xai-grok-pager/src/headless.rs +++ b/crates/codegen/xai-grok-pager/src/headless.rs @@ -26,7 +26,7 @@ use xai_grok_shell::sampling::types::{ use xai_grok_shell::util::config as cli_config; use crate::acp::model_state::{EffortTokenError, ModelState}; -use crate::acp::spawn::spawn_grok_shell; +use crate::acp::spawn::{AgentShutdownGuard, spawn_grok_shell}; use crate::client_identity::{HEADLESS_CLIENT_TYPE, PAGER_CLIENT_VERSION}; // ── Types ──────────────────────────────────────────────────────────────── @@ -928,6 +928,8 @@ pub async fn run_single_turn( anyhow::bail!("{msg}"); } }; + // Cancel + join on every return path (success or bail). + let _agent_guard = AgentShutdownGuard::new(cancel.clone(), Some(spawned.thread_handle)); let (acp_tx, mut acp_rx) = (spawned.channel.tx, spawned.channel.rx); crate::unified_log::init(acp_tx.clone()); crate::unified_log::info( @@ -947,7 +949,6 @@ pub async fn run_single_turn( Err(e) => { let msg = format!("Couldn't initialize: {e}"); emitter.on_error(&msg); - cancel.cancel(); anyhow::bail!("{msg}"); } }; @@ -969,7 +970,6 @@ pub async fn run_single_turn( Ok(is_api_key) => is_api_key, Err(e) => { emitter.on_error(&e.to_string()); - cancel.cancel(); return Err(e); } }; @@ -1041,7 +1041,6 @@ pub async fn run_single_turn( Err(e) => { let msg = format!("Couldn't create session: {e}"); emitter.on_error(&msg); - cancel.cancel(); anyhow::bail!("{msg}"); } }; @@ -1075,7 +1074,6 @@ pub async fn run_single_turn( { let msg = e.to_string(); emitter.on_error(&msg); - cancel.cancel(); anyhow::bail!("{msg}"); } @@ -1177,7 +1175,6 @@ pub async fn run_single_turn( msg = acp_rx.recv() => { let Some(msg) = msg else { emitter.on_error("Connection closed unexpectedly"); - cancel.cancel(); anyhow::bail!("Connection closed unexpectedly"); }; handle_headless_acp_message( @@ -1253,7 +1250,7 @@ pub async fn run_single_turn( // Non-blocking flock so a slow/network ~/.grok can't hang exit. let _ = xai_grok_shell::active_sessions::try_unregister(&session_id); } - cancel.cancel(); + // Agent cancel + join (SessionEnd flush) runs in AgentShutdownGuard::drop. match prompt_result { Some(Ok(resp)) => { let stop_reason = format!("{:?}", resp.stop_reason); diff --git a/crates/codegen/xai-grok-pager/src/minimal/api.rs b/crates/codegen/xai-grok-pager/src/minimal/api.rs index 194a623..5717a8b 100644 --- a/crates/codegen/xai-grok-pager/src/minimal/api.rs +++ b/crates/codegen/xai-grok-pager/src/minimal/api.rs @@ -562,10 +562,7 @@ pub fn resolve_turn_activity(v: &AgentView) -> Option { v.resolve_turn_activity() } -/// [`AgentView::renders_parked`] — while the parked-wait marker's turn is -/// parked, minimal renders the "… still running" cue (watchers running) or the -/// idle hint (none), mirroring the full TUI. The marker itself is pushed by -/// the shared ACP notification path, so minimal's scrollback carries it too. +/// [`AgentView::renders_parked`]. pub fn renders_parked(v: &AgentView) -> bool { v.renders_parked() } diff --git a/crates/codegen/xai-grok-pager/src/models.rs b/crates/codegen/xai-grok-pager/src/models.rs index 32c98a2..966a981 100644 --- a/crates/codegen/xai-grok-pager/src/models.rs +++ b/crates/codegen/xai-grok-pager/src/models.rs @@ -21,6 +21,9 @@ pub async fn list_available_models(agent_config: &AgentConfig) -> Result<()> { let cancel = CancellationToken::new(); let spawned = crate::acp::spawn::spawn_grok_shell(agent_config.clone(), &cancel, None).await?; + // Cancel + join on every return path, including the `?` below. + let _agent_guard = + crate::acp::spawn::AgentShutdownGuard::new(cancel.clone(), Some(spawned.thread_handle)); let state = list_models(&spawned.channel.tx, PAGER_CLIENT_TYPE, PAGER_CLIENT_VERSION).await?; @@ -35,6 +38,5 @@ pub async fn list_available_models(agent_config: &AgentConfig) -> Result<()> { } } - cancel.cancel(); Ok(()) } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/session_event.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/session_event.rs index ab9672f..07d1b96 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/session_event.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/session_event.rs @@ -271,10 +271,7 @@ impl SessionEvent { /// Whether this event marks the end of an agent turn (the "Turn /// completed/cancelled/failed" markers). These are the only events that - /// can carry the turn's stop/stop_failure hook runs inline — but a - /// parked marker renders mid-turn while the turn is still running - /// shell-side, before any Stop hook fires, so hook eligibility is the - /// block-level [`SessionEventBlock::accepts_stop_hooks`]. + /// can carry the turn's stop/stop_failure hook runs inline. pub fn is_turn_terminal(&self) -> bool { matches!( self, @@ -312,12 +309,6 @@ pub struct SessionEventBlock { /// The prompt turn a terminal marker belongs to, when known. Gates /// which stop-hook batches may merge into it. pub prompt_id: Option, - /// The marker was pushed at park time (user-interruptible blocking - /// wait): the turn is still running shell-side, so it must never accept - /// stop hooks. Rendering is unchanged — a parked wait reads as stopped. - /// Cleared when the completion folds into the uncommitted tail marker; - /// a committed tail (minimal print-once) gets a fresh row instead. - pub parked: bool, } impl SessionEventBlock { @@ -327,7 +318,6 @@ impl SessionEventBlock { event, stop_hooks: Vec::new(), prompt_id: None, - parked: false, } } @@ -342,17 +332,9 @@ impl SessionEventBlock { event, stop_hooks, prompt_id, - parked: false, } } - /// Whether this marker may carry/accept stop-hook runs: a turn-terminal - /// event that is not a parked line (which renders while the turn is - /// still running shell-side, before any Stop hook fires). - pub fn accepts_stop_hooks(&self) -> bool { - self.event.is_turn_terminal() && !self.parked - } - /// Whether any attached stop hook actually ran (non-skipped). Gates the /// fold/selection affordances and the inline summary, mirroring /// [`ToolCallHookData::has_content`](super::tool::ToolCallHookData::has_content). @@ -1344,44 +1326,16 @@ mod tests { ); } - /// A parked marker block — the shape `maybe_push_parked_marker` pushes. - fn parked_marker() -> SessionEventBlock { - SessionEventBlock { - event: SessionEvent::TurnCompleted { - elapsed: Some(Duration::from_secs(24)), - }, - stop_hooks: Vec::new(), - prompt_id: None, - parked: true, - } - } - #[test] - fn parked_markers_never_accept_stop_hooks() { - // A parked marker renders mid-turn, before any Stop hook fires. - let block = parked_marker(); - assert!(!block.accepts_stop_hooks(), "parked marker refuses hooks"); - - // The real terminal marker accepts. + fn only_turn_terminal_events_accept_stop_hooks() { let settled = SessionEventBlock::new(SessionEvent::TurnCompleted { elapsed: Some(Duration::from_secs(24)), }); - assert!(settled.accepts_stop_hooks()); - // Non-terminal events never accept, parked or not. + assert!(settled.event.is_turn_terminal()); let recap = SessionEventBlock::new(SessionEvent::Recap { summary: "did stuff".into(), auto: false, }); - assert!(!recap.accepts_stop_hooks()); - } - - #[test] - fn parked_marker_output_reads_as_plain_completed_marker() { - // The parked marker renders the plain event text — still-running - // background work is the status row's "… still running" cue, never a - // transcript suffix. - let block = parked_marker(); - let out = block.output(&ctx()); - assert_eq!(plain(&out.lines[0]), "Worked for 24s"); + assert!(!recap.event.is_turn_terminal()); } } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/state/mod.rs b/crates/codegen/xai-grok-pager/src/scrollback/state/mod.rs index 93a0cfd..dd019f3 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/state/mod.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/state/mod.rs @@ -767,11 +767,7 @@ impl ScrollbackState { /// failed") that can accept a live `stop`/`stop_failure` batch arriving /// after the marker (viewer order). The walk skips blocks appended after /// the marker. A stamped batch needs the marker to carry the same prompt - /// id — and treats parked markers as transparent: they never - /// accept hooks themselves (their turn is still running), and pid-exact - /// attribution cannot misattach, so a late prior-turn batch may cross - /// the current turn's not-yet-settled boundary into its own turn's - /// marker. An unstamped batch is positional (tail only) and stops at ANY + /// id. An unstamped batch is positional (tail only) and stops at ANY /// terminal-event marker — without a pid there is no proof it belongs /// further back. A same-name repeat (e.g. the session-end `stop`) is /// always refused. @@ -787,14 +783,6 @@ impl ScrollbackState { if !b.event.is_turn_terminal() { continue; } - if !b.accepts_stop_hooks() { - // Parked: transparent to a stamped batch, a hard stop for - // a positional one. - if batch_prompt_id.is_some() { - continue; - } - return None; - } if b.stop_hooks.iter().any(|(name, _)| name == event_name) { return None; } @@ -812,9 +800,8 @@ impl ScrollbackState { /// entry and collapse it so the right-justified summary — not the /// fold-out detail — is the resting state. Returns `false` unless the /// entry is a turn-terminal session event the batch can be attributed to - /// (see [`Self::latest_turn_marker_accepting`]) — never a parked - /// marker; re-checked here so a stray caller can't attach hooks to the - /// wrong entry. + /// (see [`Self::latest_turn_marker_accepting`]); re-checked here so a + /// stray caller can't attach hooks to the wrong entry. pub fn attach_stop_hooks_to_marker( &mut self, id: EntryId, @@ -828,7 +815,7 @@ impl ScrollbackState { let RenderBlock::SessionEvent(ref mut block) = entry.block else { return false; }; - if !block.accepts_stop_hooks() { + if !block.event.is_turn_terminal() { return false; } let attributable = match (batch_prompt_id, block.prompt_id.as_deref()) { @@ -848,59 +835,6 @@ impl ScrollbackState { true } - /// Fold a turn completion into a tail-adjacent parked "Worked for X" - /// marker from the same prompt turn: the parked row already IS the - /// turn's boundary, so it takes the final elapsed + stop hooks in place - /// (unparked) instead of an identical row stacking beneath it. Returns - /// `false` (caller pushes a fresh marker) when the tail doesn't match, - /// the event isn't a completion (a failure/cancel is a different - /// outcome), or minimal mode already committed the row — print-once: an - /// in-place mutation would never reach the terminal. - pub fn fold_completion_into_tail_parked_marker( - &mut self, - event: &super::blocks::SessionEvent, - stop_hooks: &[(String, Vec)], - prompt_id: Option<&str>, - ) -> bool { - use super::blocks::SessionEvent; - // `is_none` also keeps `None` from matching a pid-less parked marker. - if prompt_id.is_none() || !matches!(event, SessionEvent::TurnCompleted { .. }) { - return false; - } - let tail_match = self.last().and_then(|entry| match &entry.block { - RenderBlock::SessionEvent(b) if b.parked && b.prompt_id.as_deref() == prompt_id => { - Some(entry.id) - } - _ => None, - }); - let Some(id) = tail_match else { - return false; - }; - if self.is_committed(id) { - return false; - } - let Some(entry) = self.entries.get_mut(&id) else { - return false; - }; - let RenderBlock::SessionEvent(ref mut b) = entry.block else { - return false; - }; - b.event = event.clone(); - b.parked = false; - b.stop_hooks = stop_hooks.to_vec(); - // A hook-carrying marker rests Collapsed (the right-justified summary) - // on every sibling path — fresh pushes via `default_display_mode` and - // `attach_stop_hooks_to_marker` — so the fold matches. - if b.has_stop_hook_content() && !entry.display_mode_pinned { - entry.display_mode = DisplayMode::Collapsed; - } - entry.invalidate_cache(); - self.mark_structurally_dirty(id); - // The marker's searchable text changed (parked elapsed → final). - self.bump_content_generation(); - true - } - /// Push a text chunk to an agent message entry. /// /// This is the preferred way to append streaming content because it: @@ -2264,17 +2198,6 @@ mod tests { }; let mut state = ScrollbackState::new(); - // A parked marker renders mid-turn — it must never accept hooks, at - // the lookup and at the mutation site alike. - let mut parked_block = - crate::scrollback::blocks::SessionEventBlock::new(SessionEvent::TurnCompleted { - elapsed: Some(std::time::Duration::from_secs(1)), - }); - parked_block.parked = true; - let parked = state.push_block(RenderBlock::SessionEvent(parked_block)); - assert_eq!(state.latest_turn_marker_accepting("stop", None), None); - assert!(!state.attach_stop_hooks_to_marker(parked, "stop".into(), entries(), None)); - let marker = state.push_block(RenderBlock::session_event(SessionEvent::TurnCompleted { elapsed: Some(std::time::Duration::from_secs(2)), })); @@ -2412,68 +2335,6 @@ mod tests { ); } - #[test] - fn stamped_stop_hooks_cross_parked_marker_to_their_turns_marker() { - use crate::scrollback::blocks::tool::{HookRunEntry, HookRunStatus}; - use crate::scrollback::blocks::{SessionEvent, SessionEventBlock}; - let entries = || { - vec![HookRunEntry { - name: "h".into(), - status: HookRunStatus::Success { - elapsed: std::time::Duration::from_millis(1), - }, - output: None, - }] - }; - - // A prior turn's settled marker, then the current turn's parked - // EndLine at the tail (viewer/reattach shape). - let mut state = ScrollbackState::new(); - let prior = state.push_block(RenderBlock::SessionEvent( - SessionEventBlock::with_stop_hooks( - SessionEvent::TurnCompleted { - elapsed: Some(std::time::Duration::from_secs(2)), - }, - Vec::new(), - Some("pid-a".into()), - ), - )); - let mut parked_block = SessionEventBlock::new(SessionEvent::TurnCompleted { - elapsed: Some(std::time::Duration::from_secs(1)), - }); - parked_block.parked = true; - parked_block.prompt_id = Some("pid-b".into()); - let parked = state.push_block(RenderBlock::SessionEvent(parked_block)); - - // A late batch stamped for the PRIOR turn crosses the parked marker - // (pid-exact attribution cannot misattach) and merges into its own - // turn's marker; the parked marker itself is untouched. - assert_eq!( - state.latest_turn_marker_accepting("stop", Some("pid-a")), - Some(prior) - ); - assert!(state.attach_stop_hooks_to_marker(prior, "stop".into(), entries(), Some("pid-a"))); - match &state.get_by_id(parked).unwrap().block { - RenderBlock::SessionEvent(b) => { - assert!(b.parked); - assert!(b.stop_hooks.is_empty(), "the parked marker stays clean"); - } - other => panic!("expected the parked marker, got {other:?}"), - } - - // The parked turn's own pid still never accepts (its Stop hooks - // cannot have fired yet), and an unstamped positional batch stops at - // the parked tail marker as before. - assert_eq!( - state.latest_turn_marker_accepting("stop_failure", Some("pid-b")), - None - ); - assert_eq!( - state.latest_turn_marker_accepting("stop_failure", None), - None - ); - } - /// A finished user `!` command expands to its full output; a Collapsed /// entry keeps its fold (no snap-open at completion). #[test] diff --git a/crates/codegen/xai-grok-pager/src/settings/mod.rs b/crates/codegen/xai-grok-pager/src/settings/mod.rs index 448ee3d..643ab58 100644 --- a/crates/codegen/xai-grok-pager/src/settings/mod.rs +++ b/crates/codegen/xai-grok-pager/src/settings/mod.rs @@ -27,9 +27,9 @@ pub mod defs; pub mod registry; pub use registry::{ - DynamicEnumSource, EnumChoice, OwnedEnumChoice, PagerLocalSnapshot, SettingCategory, - SettingKey, SettingKind, SettingMeta, SettingOwner, SettingValue, SettingsRegistry, - StringValidator, canonical_hunk_tracker_mode, canonical_screen_mode, + CodingDataSharingLock, DynamicEnumSource, EnumChoice, OwnedEnumChoice, PagerLocalSnapshot, + SettingCategory, SettingKey, SettingKind, SettingMeta, SettingOwner, SettingValue, + SettingsRegistry, StringValidator, canonical_hunk_tracker_mode, canonical_screen_mode, canonical_voice_capture_mode, canonical_voice_stt_language, current_value_for, default_value_for, dynamic_enum_choices, }; diff --git a/crates/codegen/xai-grok-pager/src/settings/registry.rs b/crates/codegen/xai-grok-pager/src/settings/registry.rs index 3c0fd16..6db1e1a 100644 --- a/crates/codegen/xai-grok-pager/src/settings/registry.rs +++ b/crates/codegen/xai-grok-pager/src/settings/registry.rs @@ -226,6 +226,23 @@ pub enum SettingValue { Int(i64), } +/// Why `coding_data_sharing` cannot be changed in the settings modal. +/// Computed by `AppView::coding_data_sharing_lock`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CodingDataSharingLock { + Zdr, + TeamManaged, +} + +impl CodingDataSharingLock { + pub fn reason(self) -> &'static str { + match self { + Self::Zdr => "Your team has Zero Data Retention.", + Self::TeamManaged => "Managed by your team admin.", + } + } +} + /// Snapshot of pager-local state captured when the modal opens. /// Used by `current_value_for` to render against LIVE state rather /// than the on-disk `UiConfig`. Refreshed by @@ -252,6 +269,8 @@ pub struct PagerLocalSnapshot { /// `opt_out == false` → canonical "opt-in". Snapshot default is /// `true` (opted out) to match the safer consumer default. pub coding_data_sharing_opt_out: bool, + /// Why `coding_data_sharing` cannot be changed here (`None` = editable). + pub coding_data_sharing_lock: Option, /// Whether plan mode is active. Uses effective state /// (`pending.unwrap_or(active)`) so rapid toggles don't double-send. /// Refreshed on all mutation paths including ACP `CurrentModeUpdate`. @@ -291,6 +310,7 @@ impl Default for PagerLocalSnapshot { current_model_name: None, available_models: Vec::new(), coding_data_sharing_opt_out: true, + coding_data_sharing_lock: None, plan_mode_active: false, show_tips: None, auto_update: None, diff --git a/crates/codegen/xai-grok-pager/src/share_cmd.rs b/crates/codegen/xai-grok-pager/src/share_cmd.rs index 51bd355..ebff036 100644 --- a/crates/codegen/xai-grok-pager/src/share_cmd.rs +++ b/crates/codegen/xai-grok-pager/src/share_cmd.rs @@ -15,6 +15,9 @@ pub struct ShareArgs { pub async fn run(args: &ShareArgs, agent_config: &AgentConfig) -> Result<()> { let cancel = CancellationToken::new(); let spawned = crate::acp::spawn::spawn_grok_shell(agent_config.clone(), &cancel, None).await?; + // Cancel + join on every return path, including the `?`s below. + let _agent_guard = + crate::acp::spawn::AgentShutdownGuard::new(cancel.clone(), Some(spawned.thread_handle)); let _init: acp::InitializeResponse = acp_send( acp::InitializeRequest::new(acp::ProtocolVersion::V1) @@ -44,6 +47,5 @@ pub async fn run(args: &ShareArgs, agent_config: &AgentConfig) -> Result<()> { let response: ShareSessionResponse = serde_json::from_str(ext_resp.0.get())?; println!("{}", response.share_url); - cancel.cancel(); Ok(()) } diff --git a/crates/codegen/xai-grok-pager/src/views/dashboard/peek.rs b/crates/codegen/xai-grok-pager/src/views/dashboard/peek.rs index 5b2df3a..c94083d 100644 --- a/crates/codegen/xai-grok-pager/src/views/dashboard/peek.rs +++ b/crates/codegen/xai-grok-pager/src/views/dashboard/peek.rs @@ -571,7 +571,7 @@ pub fn render_peek_panel( live_tail: Option>, empty_hint: Option<&str>, ) -> PeekRenderResult { - use crate::views::prompt_widget::PromptStyle; + use crate::views::prompt_widget::{PromptBg, PromptStyle}; use ratatui::widgets::{Block, BorderType, Borders, Widget}; use unicode_width::UnicodeWidthStr; if area.area() == 0 || area.height < 3 || area.width < 20 { @@ -724,7 +724,7 @@ pub fn render_peek_panel( show_prefix: false, vpad_top: 0, chrome: false, - bg_override: Some(theme.bg_base), + bg: PromptBg::Canvas(theme.bg_base), image_preview: false, ..PromptStyle::default() }; @@ -863,7 +863,7 @@ pub fn render_peek_panel( show_prefix: false, vpad_top: 0, chrome: false, - bg_override: Some(theme.bg_base), + bg: PromptBg::Canvas(theme.bg_base), placeholder_override: Some("reply\u{2026}"), image_preview: false, ..PromptStyle::default() diff --git a/crates/codegen/xai-grok-pager/src/views/dashboard/render.rs b/crates/codegen/xai-grok-pager/src/views/dashboard/render.rs index b6da93a..9b60e27 100644 --- a/crates/codegen/xai-grok-pager/src/views/dashboard/render.rs +++ b/crates/codegen/xai-grok-pager/src/views/dashboard/render.rs @@ -2852,7 +2852,7 @@ fn render_dispatch( ) -> Option<(u16, u16)> { use ratatui::widgets::{Block, BorderType, Borders, Widget}; - use crate::views::prompt_widget::PromptStyle; + use crate::views::prompt_widget::{PromptBg, PromptStyle}; if area.area() == 0 { return None; @@ -3028,7 +3028,7 @@ fn render_dispatch( show_prefix: true, vpad_top: 0, chrome: false, - bg_override: Some(theme.bg_base), + bg: PromptBg::Canvas(theme.bg_base), image_preview: false, ..PromptStyle::default() }; diff --git a/crates/codegen/xai-grok-pager/src/views/prompt_widget/mod.rs b/crates/codegen/xai-grok-pager/src/views/prompt_widget/mod.rs index 42c70ce..3ac8bb5 100644 --- a/crates/codegen/xai-grok-pager/src/views/prompt_widget/mod.rs +++ b/crates/codegen/xai-grok-pager/src/views/prompt_widget/mod.rs @@ -158,10 +158,8 @@ pub struct PromptStyle { /// Only used when `chrome` is true. pub chrome_pad_left: u16, pub chrome_pad_right: u16, - /// Override the background color. When `Some`, the prompt uses this bg - /// instead of computing one from focus state. Useful for rendering the - /// prompt inline within another widget (e.g., question view). - pub bg_override: Option, + /// Background surface for the prompt; see [`PromptBg`]. + pub bg: PromptBg, /// Override the accent line color. When `Some`, uses this color instead /// of the default `accent_user` / `gray_dim`. Used for plan mode (golden). pub accent_color_override: Option, @@ -194,6 +192,35 @@ pub struct PromptStyle { pub image_preview: bool, } +/// Background for the prompt widget. +/// +/// Paste chips bake `theme.paste_bg` — a badge color tuned for the default +/// canvas — into their display `Line` at paste time, so the background says +/// what *kind* of surface the prompt sits on, not just its color. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PromptBg { + /// The standalone prompt's default fill (`theme.bg_base`). + #[default] + Default, + /// Explicit canvas color for prompts rendered inline within another + /// widget whose surface matches the main prompt's (dashboard dispatch + /// box, peek reply). Chips keep their badge background. + Canvas(ratatui::style::Color), + /// Inline panel color (question freeform input, permission follow-up). + /// Chip cells are repainted to blend into the panel. + Panel(ratatui::style::Color), +} + +impl PromptBg { + /// Effective fill color; `default` is the standalone prompt's. + fn color(self, default: ratatui::style::Color) -> ratatui::style::Color { + match self { + Self::Default => default, + Self::Canvas(c) | Self::Panel(c) => c, + } + } +} + impl Default for PromptStyle { fn default() -> Self { Self { @@ -203,7 +230,7 @@ impl Default for PromptStyle { chrome: true, chrome_pad_left: 2, chrome_pad_right: 1, - bg_override: None, + bg: PromptBg::Default, accent_color_override: None, border_color_override: None, prefix_override: None, @@ -237,7 +264,7 @@ impl PromptStyle { chrome: false, chrome_pad_left: 0, chrome_pad_right: 0, - bg_override: Some(bg), + bg: PromptBg::Panel(bg), accent_color_override: None, border_color_override: None, prefix_override: None, @@ -986,6 +1013,17 @@ impl PromptWidget { self.update_file_search_context(); } + /// [`Self::set_text`] unless the buffer already holds exactly `text`. + /// + /// Skipping the no-op swap keeps chip elements, images, and undo history + /// intact when a surface reloads an unchanged draft (the question view's + /// freeform slots); any real content change takes the normal reset path. + pub fn set_text_preserving(&mut self, text: &str) { + if self.text() != text { + self.set_text(text); + } + } + /// Append plain text at the end without replacing existing chip elements. pub fn append_text(&mut self, text: &str) { if text.is_empty() { @@ -2856,11 +2894,7 @@ impl PromptWidget { } let theme = Theme::current(); - let bg = if let Some(override_bg) = style.bg_override { - override_bg - } else { - theme.bg_base - }; + let bg = style.bg.color(theme.bg_base); let border_color = style.border_color_override.unwrap_or(if style.focused { theme.prompt_border_active @@ -2987,6 +3021,21 @@ impl PromptWidget { (&self.textarea).render_ref(ta_area, buf, &mut self.textarea_state); + // Chip bg remap (see `PromptBg::Panel`): chip `Line`s bake in + // `paste_bg` at paste time and the same element can render on + // multiple surfaces, so restyle at paint time. + if matches!(style.bg, PromptBg::Panel(_)) && bg != theme.paste_bg { + for y in ta_area.top()..ta_area.bottom() { + for x in ta_area.left()..ta_area.right() { + if let Some(cell) = buf.cell_mut((x, y)) + && cell.bg == theme.paste_bg + { + cell.bg = bg; + } + } + } + } + // Slash overlays: teal command name + args ghost text. Both use the // same snapshot, so clone once. Capture flags for later ghost text // suppression to avoid a second clone. @@ -3194,8 +3243,8 @@ impl PromptWidget { } // Unfocused dimming: blend fg toward bg (bg already precomputed above). - // Skip when bg_override is set — the prompt is inline in another widget. - if !style.focused && style.bg_override.is_none() { + // Skip when the bg is overridden — the prompt is inline in another widget. + if !style.focused && style.bg == PromptBg::Default { // Dim only the content inside the box (skip all border chars). let dim_area = Rect { x: area.x + 1, diff --git a/crates/codegen/xai-grok-pager/src/views/prompt_widget/tests.rs b/crates/codegen/xai-grok-pager/src/views/prompt_widget/tests.rs index b02c877..cbcdb2b 100644 --- a/crates/codegen/xai-grok-pager/src/views/prompt_widget/tests.rs +++ b/crates/codegen/xai-grok-pager/src/views/prompt_widget/tests.rs @@ -4573,3 +4573,53 @@ let buf = draw_bordered(11, &title_test_style(Some("my session"))); assert_eq!(buf_text_at(&buf, 1, 10, 0), "\u{2500}".repeat(9)); } + + // ── PromptBg::Panel chip remap (inline surfaces) ──────────────── + + fn any_cell_with_bg(buf: &Buffer, bg: ratatui::style::Color) -> bool { + let area = *buf.area(); + (area.top()..area.bottom()) + .any(|y| (area.left()..area.right()).any(|x| buf.cell((x, y)).is_some_and(|c| c.bg == bg))) + } + + /// Inline surfaces repaint the chip's baked-in `paste_bg` to the panel + /// background; without the flag the chip keeps its own background. Uses + /// a sentinel panel color so the test holds under terminal-default, + /// where every palette entry quantizes to `Color::Reset`. + #[test] + fn panel_bg_repaints_paste_chip_to_panel_bg() { + let theme = Theme::current(); + let panel = ratatui::style::Color::Rgb(12, 34, 56); + assert_ne!(theme.paste_bg, panel, "fixture: sentinel must differ"); + + let mut pw = PromptWidget::new(); + pw.handle_paste("a\nb\nc\nd\ne"); // 5 lines >= chip threshold (4) + let area = Rect::new(0, 0, 40, 2); + + let inline = PromptStyle::inline(panel); + assert!( + matches!(inline.bg, PromptBg::Panel(_)), + "inline surfaces are panels" + ); + let mut buf = Buffer::empty(area); + pw.draw(&mut buf, area, None, &inline, None, None); + assert!( + !any_cell_with_bg(&buf, theme.paste_bg), + "chip cells must be repainted to the panel background" + ); + assert!( + any_cell_with_bg(&buf, panel), + "the chip row renders on the panel background" + ); + + let no_remap = PromptStyle { + bg: PromptBg::Canvas(panel), + ..PromptStyle::inline(panel) + }; + let mut buf = Buffer::empty(area); + pw.draw(&mut buf, area, None, &no_remap, None, None); + assert!( + any_cell_with_bg(&buf, theme.paste_bg), + "without the remap the chip keeps its own background" + ); + } diff --git a/crates/codegen/xai-grok-pager/src/views/settings_modal/render.rs b/crates/codegen/xai-grok-pager/src/views/settings_modal/render.rs index 0a34337..524540a 100644 --- a/crates/codegen/xai-grok-pager/src/views/settings_modal/render.rs +++ b/crates/codegen/xai-grok-pager/src/views/settings_modal/render.rs @@ -13,8 +13,8 @@ use super::state::{ }; use crate::render::line_utils::truncate_str; use crate::settings::{ - OwnedEnumChoice, SettingKey, SettingKind, SettingMeta, SettingValue, StringValidator, - dynamic_enum_choices, + CodingDataSharingLock, OwnedEnumChoice, SettingKey, SettingKind, SettingMeta, SettingValue, + StringValidator, dynamic_enum_choices, }; use crate::theme::Theme; use crate::views::modal_window::{ @@ -644,9 +644,9 @@ pub(super) fn render_rows( width: area.width, height: desc_height.min(8), }; - render_expanded_description(buf, desc_rect, meta, theme); + render_expanded_description(buf, desc_rect, meta, None, theme); let consumed = - wrapped_description_height(meta, area.width, desc_rect.height); + wrapped_description_height(meta, None, area.width, desc_rect.height); y_cursor = y_cursor.saturating_add(consumed); } continue; @@ -668,25 +668,10 @@ pub(super) fn render_rows( } }; + let lock = state.row_lock(key); + // Decide 1 vs 2 line layout; fall back to 1 if viewport is tight. - let value_display = match value { - SettingValue::Bool(b) => { - if *b { - "on".to_string() - } else { - "off".to_string() - } - } - SettingValue::String(s) => { - if s.is_empty() && matches!(meta.kind, SettingKind::DynamicEnum { .. }) { - "(no override)".to_string() - } else { - s.clone() - } - } - SettingValue::Enum(e) => display_for_enum_canonical(&meta.kind, e).to_string(), - SettingValue::Int(i) => i.to_string(), - }; + let value_display = value_display(meta, value, lock); let show_restart_pill_for_layout = meta.restart_required && is_expanded; let layout_decision = row_layout( area.width, @@ -722,6 +707,7 @@ pub(super) fn render_rows( theme, is_expanded, is_hovered, + lock, ); state.value_hit_rects[row_idx] = value_rect; y_cursor = y_cursor.saturating_add(row_height); @@ -734,10 +720,12 @@ pub(super) fn render_rows( width: area.width, height: desc_height.min(8), // cap at 8 lines per row to keep scroll sane }; - render_expanded_description(buf, desc_rect, meta, theme); + let lock_reason = lock.map(CodingDataSharingLock::reason); + render_expanded_description(buf, desc_rect, meta, lock_reason, theme); // Re-measure how many lines the wrapped description // actually consumed, so y_cursor advances precisely. - let consumed = wrapped_description_height(meta, area.width, desc_rect.height); + let consumed = + wrapped_description_height(meta, lock_reason, area.width, desc_rect.height); y_cursor = y_cursor.saturating_add(consumed); } } @@ -824,7 +812,7 @@ fn compute_filtered_row_heights(state: &SettingsModalState, area_width: u16) -> if matches!(meta.kind, SettingKind::Group { .. }) { let mut h: u16 = 1; if state.expanded_keys.contains(key) { - h = h.saturating_add(wrapped_description_height(meta, area_width, 8)); + h = h.saturating_add(wrapped_description_height(meta, None, area_width, 8)); } heights.push(h); continue; @@ -834,24 +822,8 @@ fn compute_filtered_row_heights(state: &SettingsModalState, area_width: u16) -> continue; }; let is_expanded = state.expanded_keys.contains(key); - let value_display = match &value { - SettingValue::Bool(b) => { - if *b { - "on".to_string() - } else { - "off".to_string() - } - } - SettingValue::String(s) => { - if s.is_empty() && matches!(meta.kind, SettingKind::DynamicEnum { .. }) { - "(no override)".to_string() - } else { - s.clone() - } - } - SettingValue::Enum(e) => display_for_enum_canonical(&meta.kind, e).to_string(), - SettingValue::Int(i) => i.to_string(), - }; + let lock = state.row_lock(key); + let value_display = value_display(meta, &value, lock); let show_restart_pill = meta.restart_required && is_expanded; let layout = row_layout(area_width, meta.label, &value_display, show_restart_pill); let mut h: u16 = match layout { @@ -861,7 +833,12 @@ fn compute_filtered_row_heights(state: &SettingsModalState, area_width: u16) -> if is_expanded { // Cap matches the forward render loop at line // 2040 (`desc_rect.height = ... .min(8)`). - h = h.saturating_add(wrapped_description_height(meta, area_width, 8)); + h = h.saturating_add(wrapped_description_height( + meta, + lock.map(CodingDataSharingLock::reason), + area_width, + 8, + )); } heights.push(h); } @@ -871,13 +848,19 @@ fn compute_filtered_row_heights(state: &SettingsModalState, area_width: u16) -> } /// Wrapped description height for scroll math (mirrors render path). -fn wrapped_description_height(meta: &SettingMeta, area_width: u16, cap: u16) -> u16 { +fn wrapped_description_height( + meta: &SettingMeta, + lock_reason: Option<&'static str>, + area_width: u16, + cap: u16, +) -> u16 { let indent = 4u16.min(area_width); let wrap_w = area_width.saturating_sub(indent); if wrap_w == 0 { return 0; } - let line = Line::from(Span::raw(meta.description)); + let text = lock_reason.unwrap_or(meta.description); + let line = Line::from(Span::raw(text)); let wrapped = crate::render::wrapping::word_wrap_line(&line, wrap_w as usize); (wrapped.len() as u16).min(cap) } @@ -2230,6 +2213,37 @@ const ROW_CHEVRON_W: u16 = 2; /// Chevron column width — reserved for all rows for alignment. pub(super) const ROW_CHEVRON_COL_W: u16 = ROW_CHEVRON_W; const ROW_RESTART_PILL_W: u16 = 10; // " · restart" — used for layout budgeting only. +/// Appended to the value column of a locked row (see `SettingsModalState::row_lock`). +pub(super) const ROW_ADMIN_MANAGED_SUFFIX: &str = " \u{00B7} Admin Managed"; +/// Value column for ZDR-locked rows — replaces the opt-in/out value entirely. +pub(super) const ROW_ZDR_VALUE: &str = "ZDR"; + +/// Value-column text, shared by layout, scroll math, and paint. +pub(super) fn value_display( + meta: &SettingMeta, + value: &SettingValue, + lock: Option, +) -> String { + if lock == Some(CodingDataSharingLock::Zdr) { + return ROW_ZDR_VALUE.to_string(); + } + let mut display = match value { + SettingValue::Bool(b) => if *b { "on" } else { "off" }.to_string(), + SettingValue::String(s) => { + if s.is_empty() && matches!(meta.kind, SettingKind::DynamicEnum { .. }) { + "(no override)".to_string() + } else { + s.clone() + } + } + SettingValue::Enum(e) => display_for_enum_canonical(&meta.kind, e).to_string(), + SettingValue::Int(i) => i.to_string(), + }; + if lock == Some(CodingDataSharingLock::TeamManaged) { + display.push_str(ROW_ADMIN_MANAGED_SUFFIX); + } + display +} /// Per-row layout decision. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -2309,6 +2323,7 @@ pub(super) fn render_setting_row( theme: &Theme, is_expanded: bool, is_hovered: bool, + lock: Option, ) -> Rect { let bg = settings_list_row_bg(theme, is_selected, is_hovered); // Paint the row bg across the full area (1 or 2 lines). @@ -2327,43 +2342,24 @@ pub(super) fn render_setting_row( .add_modifier(Modifier::ITALIC); let desc_style = Style::default().fg(theme.gray).bg(bg); - // Enum rows display the user-friendly name, not the canonical. - let value_text_owned; - let value_text: &str = match value { - SettingValue::Bool(b) => { - if *b { - "on" - } else { - "off" - } - } - SettingValue::String(s) => { - if s.is_empty() && matches!(meta.kind, SettingKind::DynamicEnum { .. }) { - "(no override)" - } else { - s.as_str() - } - } - SettingValue::Enum(e) => display_for_enum_canonical(&meta.kind, e), - SettingValue::Int(i) => { - value_text_owned = i.to_string(); - &value_text_owned - } - }; + let value_text = value_display(meta, value, lock); + let value_text = value_text.as_str(); - let value_style = if matches!(value, SettingValue::Bool(false)) { + let value_style = if lock.is_some() || matches!(value, SettingValue::Bool(false)) { Style::default().fg(theme.gray).bg(bg) } else { value_style }; // Chevron for Enum/String/DynamicEnum (opens picker/editor). - let show_chevron = matches!( - (&meta.kind, value), - (SettingKind::Enum { .. }, _) - | (SettingKind::String { .. }, _) - | (SettingKind::DynamicEnum { .. }, _) - ); + // Locked rows can't be entered, so they drop the affordance. + let show_chevron = lock.is_none() + && matches!( + (&meta.kind, value), + (SettingKind::Enum { .. }, _) + | (SettingKind::String { .. }, _) + | (SettingKind::DynamicEnum { .. }, _) + ); let chevron_str = format!(" {}", crate::glyphs::chevron()); // › → > on legacy ConHost let chevron_w = if show_chevron { chevron_str.width() as u16 @@ -2589,7 +2585,13 @@ pub(super) fn render_setting_row( } /// Render the wrapped description for an expanded row. -fn render_expanded_description(buf: &mut Buffer, area: Rect, meta: &SettingMeta, theme: &Theme) { +fn render_expanded_description( + buf: &mut Buffer, + area: Rect, + meta: &SettingMeta, + lock_reason: Option<&'static str>, + theme: &Theme, +) { if area.height == 0 || area.width == 0 { return; } @@ -2597,14 +2599,14 @@ fn render_expanded_description(buf: &mut Buffer, area: Rect, meta: &SettingMeta, .fg(theme.gray) .bg(theme.bg_base) .add_modifier(Modifier::ITALIC); - let desc_src: &str = meta.description; + let desc_text = lock_reason.unwrap_or(meta.description); // Indent 4 cols to nest under the label. let indent = 4u16.min(area.width); let wrap_w = area.width.saturating_sub(indent); if wrap_w == 0 { return; } - let line = Line::from(Span::styled(desc_src, desc_style)); + let line = Line::from(Span::styled(desc_text, desc_style)); let wrapped = crate::render::wrapping::word_wrap_line(&line, wrap_w as usize); for (i, wrapped_line) in wrapped.iter().enumerate() { if (i as u16) >= area.height { diff --git a/crates/codegen/xai-grok-pager/src/views/settings_modal/state.rs b/crates/codegen/xai-grok-pager/src/views/settings_modal/state.rs index b6be0b9c..565dd1b 100644 --- a/crates/codegen/xai-grok-pager/src/views/settings_modal/state.rs +++ b/crates/codegen/xai-grok-pager/src/views/settings_modal/state.rs @@ -7,9 +7,9 @@ use ratatui::layout::Rect; use crate::app::actions::Action; use crate::input::line_editor::LineEditor; use crate::settings::{ - EnumChoice, OwnedEnumChoice, PagerLocalSnapshot, SettingCategory, SettingKey, SettingKind, - SettingMeta, SettingValue, SettingsRegistry, StringValidator, current_value_for, - dynamic_enum_choices, + CodingDataSharingLock, EnumChoice, OwnedEnumChoice, PagerLocalSnapshot, SettingCategory, + SettingKey, SettingKind, SettingMeta, SettingValue, SettingsRegistry, StringValidator, + current_value_for, dynamic_enum_choices, }; use crate::views::modal_window::ModalWindowState; @@ -243,6 +243,16 @@ impl SettingsModalState { } } + /// Why a Browse row cannot be edited (`None` = editable). Consulted by + /// both render and input. + pub fn row_lock(&self, key: SettingKey) -> Option { + if key == "coding_data_sharing" { + self.pager_snapshot.coding_data_sharing_lock + } else { + None + } + } + /// The currently-focused setting row, if any. pub fn focused_setting(&self) -> Option<(SettingKey, &SettingMeta)> { match self.rows.get(self.selected)? { @@ -551,6 +561,9 @@ impl SettingsModalState { let Some((key, meta)) = self.focused_setting() else { return false; }; + if self.row_lock(key).is_some() { + return false; + } // Handles both static `Enum` and `DynamicEnum` catalogs. let (supports_preview, resolved): (bool, Vec) = match &meta.kind { SettingKind::Enum { diff --git a/crates/codegen/xai-grok-pager/src/views/settings_modal/tests.rs b/crates/codegen/xai-grok-pager/src/views/settings_modal/tests.rs index 9f8e1c2..a24c5b4 100644 --- a/crates/codegen/xai-grok-pager/src/views/settings_modal/tests.rs +++ b/crates/codegen/xai-grok-pager/src/views/settings_modal/tests.rs @@ -12,8 +12,8 @@ use super::state::*; use crate::app::actions::Action; use crate::input::line_editor::LineEditor; use crate::settings::{ - EnumChoice, PagerLocalSnapshot, SettingCategory, SettingKey, SettingKind, SettingMeta, - SettingOwner, SettingValue, SettingsRegistry, StringValidator, + CodingDataSharingLock, EnumChoice, PagerLocalSnapshot, SettingCategory, SettingKey, + SettingKind, SettingMeta, SettingOwner, SettingValue, SettingsRegistry, StringValidator, }; use crate::theme::Theme; use xai_grok_shell::agent::config::UiConfig; @@ -542,6 +542,7 @@ fn render_setting_row_shows_full_label_when_one_line_fits() { &theme, false, // is_expanded false, // is_hovered + None, ); let mut rendered = String::new(); for x in 0..area.width { @@ -976,6 +977,7 @@ fn selected_browse_row_label_is_bold() { &theme, false, false, + None, ); assert!( @@ -1433,6 +1435,7 @@ fn render_setting_row_emits_restart_pill_when_required() { &theme, true, // is_expanded — gate on false, // is_hovered + None, ); let mut rendered = String::new(); for x in 0..area.width { @@ -1457,6 +1460,7 @@ fn render_setting_row_emits_restart_pill_when_required() { &theme, false, // is_expanded — off false, // is_hovered + None, ); let mut rendered = String::new(); for x in 0..area.width { @@ -1505,6 +1509,7 @@ fn render_setting_row_hides_restart_pill_when_at_default_and_collapsed() { &theme, false, // is_expanded false, // is_hovered + None, ); let mut rendered = String::new(); for x in 0..area.width { @@ -4488,6 +4493,7 @@ fn narrow_terminal_drops_value_to_second_line() { &theme, false, false, // is_hovered + None, ); let line1 = buf_row_text(&buf, 0, area.x, area.width); let line2 = buf_row_text(&buf, 1, area.x, area.width); @@ -4551,6 +4557,7 @@ fn wide_terminal_keeps_value_on_first_line() { &theme, false, false, // is_hovered + None, ); let line1 = buf_row_text(&buf, 0, area.x, area.width); let line2 = buf_row_text(&buf, 1, area.x, area.width); @@ -4592,6 +4599,7 @@ fn pathologically_narrow_truncates_label_with_ellipsis() { &theme, false, false, // is_hovered + None, ); let line1 = buf_row_text(&buf, 0, area.x, area.width); let line2 = buf_row_text(&buf, 1, area.x, area.width); @@ -5340,6 +5348,7 @@ fn bool_off_value_renders_in_dim_color() { &theme, false, false, + None, ); // Use `find_text_col` so the // column index is the actual buffer position, not a byte @@ -5372,6 +5381,7 @@ fn bool_off_value_renders_in_dim_color() { &theme, false, false, + None, ); let on_col = find_text_col(&buf_on, 0, "on").expect("must find `on` substring"); let on_cell = buf_on.cell((on_col, 0)).expect("on cell"); @@ -5443,6 +5453,7 @@ fn chevron_column_is_at_constant_right_offset() { &theme, false, false, + None, ); // Enum row — chevron column contains the `›` glyph. @@ -5457,6 +5468,7 @@ fn chevron_column_is_at_constant_right_offset() { &theme, false, false, + None, ); // The chevron column is a 2-cell block at @@ -5528,6 +5540,7 @@ fn chevron_column_is_at_constant_right_offset() { &theme, false, false, + None, ); let _ = render_setting_row( &mut buf_multi, @@ -5539,6 +5552,7 @@ fn chevron_column_is_at_constant_right_offset() { &theme, false, false, + None, ); // Bool row's `off` ends at column N; Enum row's `›` glyph // lands at column M. The contract: N == M's column @@ -5595,6 +5609,7 @@ fn chevron_column_aligns_across_one_and_two_line_layouts() { &theme, false, false, + None, ); let area_one = Rect { x: 0, @@ -5613,6 +5628,7 @@ fn chevron_column_aligns_across_one_and_two_line_layouts() { &theme, false, false, + None, ); // The column offset from the area's right edge is constant: // `area.right - ROW_RIGHT_PAD_W - 1` is the `›` glyph @@ -7470,3 +7486,180 @@ fn preview_remains_clamped_when_pending_exceeds_widened_width() { "clamped note must render when pending > interior, even after widening", ); } + +// --------------------------------------------------------------------------- +// Locked coding_data_sharing row (ZDR / team non-admin) +// --------------------------------------------------------------------------- + +fn make_locked_state(lock: CodingDataSharingLock) -> SettingsModalState { + SettingsModalState::new( + Arc::new(SettingsRegistry::defaults()), + UiConfig::default(), + PagerLocalSnapshot { + coding_data_sharing_lock: Some(lock), + ..PagerLocalSnapshot::default() + }, + ) +} + +fn coding_data_sharing_row_idx(s: &SettingsModalState) -> usize { + s.rows + .iter() + .position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "coding_data_sharing")) + .expect("coding_data_sharing must be registered") +} + +/// A locked `coding_data_sharing` row must NOT open the enum picker — +/// neither via `try_enter_picking_enum` directly (the shared entry point +/// for Enter, mouse value clicks, and the `focus_key` auto-open path) nor +/// via the Browse Enter key. With no lock, the same row opens the picker. +#[test] +fn locked_coding_data_sharing_row_does_not_open_picker() { + for lock in [ + CodingDataSharingLock::Zdr, + CodingDataSharingLock::TeamManaged, + ] { + let mut s = make_locked_state(lock); + s.selected = coding_data_sharing_row_idx(&s); + assert!( + !s.try_enter_picking_enum(), + "try_enter_picking_enum must return false for a locked row ({lock:?})" + ); + assert!( + matches!(s.mode(), SettingsModalMode::Browse), + "mode must stay Browse for a locked row ({lock:?}), got {:?}", + s.mode() + ); + let out = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!( + matches!(out, SettingsKeyOutcome::Unchanged), + "Enter on a locked row must be a no-op ({lock:?}), got {out:?}" + ); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); + } + + // Control arm: no lock → the picker opens (existing behavior). + let mut s = make_state(); + s.selected = coding_data_sharing_row_idx(&s); + assert!(s.try_enter_picking_enum()); + assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. })); +} + +/// Locked rows drop the `›` enter-affordance and render a per-variant +/// value: ZDR replaces opt-in/out with "ZDR"; team-managed keeps the +/// value with an " · Admin Managed" suffix. Unlocked rows keep the plain +/// value + chevron. +#[test] +fn locked_coding_data_sharing_row_renders_locked_value_without_chevron() { + let area = Rect { + x: 0, + y: 0, + width: 80, + height: 60, + }; + let theme = Theme::current(); + let chevron = crate::glyphs::chevron(); + + let mut s = make_locked_state(CodingDataSharingLock::Zdr); + let idx = coding_data_sharing_row_idx(&s); + s.selected = idx; + let mut buf = Buffer::empty(area); + render_rows(&mut buf, area, &mut s, &theme); + let rect = s.row_rects[idx]; + let line = buf_row_text(&buf, rect.y, area.x, area.width); + assert!( + line.contains("ZDR") && !line.contains("Opt"), + "ZDR lock must replace the opt-in/out value with `ZDR`: {line:?}" + ); + assert!( + !line.contains(chevron), + "locked row must not render the `{chevron}` enter affordance: {line:?}" + ); + + let mut s = make_locked_state(CodingDataSharingLock::TeamManaged); + s.selected = idx; + let mut buf = Buffer::empty(area); + render_rows(&mut buf, area, &mut s, &theme); + let rect = s.row_rects[idx]; + let line = buf_row_text(&buf, rect.y, area.x, area.width); + assert!( + line.contains("Opt out \u{00B7} Admin Managed"), + "team-managed lock must append ` · Admin Managed`: {line:?}" + ); + assert!( + !line.contains(chevron), + "locked row must not render the `{chevron}` enter affordance: {line:?}" + ); + + // Control arm: unlocked row shows the plain value + chevron. + let mut s = make_state(); + s.selected = idx; + let mut buf = Buffer::empty(area); + render_rows(&mut buf, area, &mut s, &theme); + let rect = s.row_rects[idx]; + let line = buf_row_text(&buf, rect.y, area.x, area.width); + assert!( + line.contains("Opt out") && !line.contains("locked"), + "unlocked row must show the plain value: {line:?}" + ); + assert!( + line.contains(chevron), + "unlocked row must keep the `{chevron}` enter affordance: {line:?}" + ); +} + +/// Expanding a locked row replaces the registry description with the lock +/// reason; the unlocked expansion shows the description. +#[test] +fn locked_coding_data_sharing_expanded_description_replaces_with_reason() { + let area = Rect { + x: 0, + y: 0, + width: 80, + height: 60, + }; + let theme = Theme::current(); + // Word-wrap may split the reason across lines; normalize the whole + // buffer to a single whitespace-collapsed string before matching. + let flatten = |buf: &Buffer| -> String { + (0..area.height) + .map(|y| buf_row_text(buf, y, area.x, area.width)) + .collect::>() + .join(" ") + .split_whitespace() + .collect::>() + .join(" ") + }; + + let mut s = make_locked_state(CodingDataSharingLock::TeamManaged); + let idx = coding_data_sharing_row_idx(&s); + s.selected = idx; + s.expanded_keys.insert("coding_data_sharing"); + let mut buf = Buffer::empty(area); + render_rows(&mut buf, area, &mut s, &theme); + let text = flatten(&buf); + assert!( + text.contains("Managed by your team admin."), + "expanded locked row must show the lock reason: {text:?}" + ); + assert!( + !text.contains("Controls whether"), + "locked expansion must replace the description, not append to it: {text:?}" + ); + + // Control arm: unlocked expansion shows the description only. + let mut s = make_state(); + s.selected = idx; + s.expanded_keys.insert("coding_data_sharing"); + let mut buf = Buffer::empty(area); + render_rows(&mut buf, area, &mut s, &theme); + let text = flatten(&buf); + assert!( + text.contains("Controls whether"), + "expanded row must render the registry description: {text:?}" + ); + assert!( + !text.contains("Managed by your team admin."), + "unlocked expansion must not mention the team-admin lock: {text:?}" + ); +} diff --git a/crates/codegen/xai-grok-pager/src/views/turn_status.rs b/crates/codegen/xai-grok-pager/src/views/turn_status.rs index e0ab002..93f010c 100644 --- a/crates/codegen/xai-grok-pager/src/views/turn_status.rs +++ b/crates/codegen/xai-grok-pager/src/views/turn_status.rs @@ -212,8 +212,7 @@ pub struct TurnStatusArgs<'a> { pub is_pending_user_input: bool, pub goal_verifying: bool, pub watchers: Watchers, - /// Parked on a sendable wait (`AgentView::renders_parked`): suppress the - /// running-turn chrome and render only the still-running cue. + /// Parked on a sendable wait (`AgentView::renders_parked`). pub parked: bool, /// Transparent right-side background so the row blends with the /// terminal's own background (minimal mode). @@ -295,39 +294,55 @@ pub fn render_turn_status( return TurnStatusOutput::default(); } - // Idle or parked with watchers: persistent still-running cue (not - // scrollback — it must never scroll away). Lower priority than the - // starting-session and drain-blocked cues above. - if (state.is_idle() || parked) - && let Some(cue) = still_running_label(watchers) - { - // Pulsing concentric circle (○ ◎ ◉ ◎) on a calm ambient cadence: - // the agent is idle, so this breath runs slower than the active - // turn spinner (see MONITOR_PULSE_DIVISOR). - let frames = crate::glyphs::monitor_icon_frames(); - let frame_idx = (tick / MONITOR_PULSE_DIVISOR) as usize % frames.len(); - let icon = format!("{} ", frames[frame_idx]); - let label_fg = if buttons.is_some_and(|b| b.watching_hovered) { - theme.text_primary + // Idle or parked: persistent cue (not scrollback — it must never scroll + // away). Lower priority than the starting-session and drain-blocked cues + // above. Parked never falls through to the running-turn chrome + // (spinner/timers/[stop]) — the wait aborts the moment the user types, + // so that chrome would lie. + if state.is_idle() || parked { + // Parked with held queued rows: the queued hint IS the input-semantics + // story (Enter acts on the queue immediately), so it replaces the + // generic interrupt copy. + let parked_suffix = if held_queue > 0 && held_queue_top_sendable { + format!(" \u{00b7} {held_queue} queued — Enter to send now") + } else if held_queue > 0 { + format!(" \u{00b7} {held_queue} queued") } else { - theme.gray + " \u{00b7} send a message to interrupt".to_string() }; - let cue_width = (icon.width() + cue.width()).min(area.width as usize) as u16; - let spans = vec![ - Span::styled(icon, Style::default().fg(theme.accent_system)), - Span::styled(cue, Style::default().fg(label_fg)), - ]; - buf.set_line(area.x, area.y, &Line::from(spans), area.width); - return TurnStatusOutput { - watching_cue: show_buttons.then(|| Rect::new(area.x, area.y, cue_width, 1)), - ..TurnStatusOutput::default() + let cue = match (still_running_label(watchers), parked) { + (Some(label), true) => Some(format!("{label}{parked_suffix}")), + (Some(label), false) => Some(label), + (None, true) => Some(format!("waiting{parked_suffix}")), + (None, false) => None, }; - } - - // Parked with no watchers left: render nothing. The stopped look must - // never fall through to the running-turn chrome (spinner/timers/[stop]) - // — the wait aborts the moment the user types, so that chrome would lie. - if parked { + if let Some(cue) = cue { + // Pulsing concentric circle (○ ◎ ◉ ◎) on a calm ambient cadence: + // the agent is idle, so this breath runs slower than the active + // turn spinner (see MONITOR_PULSE_DIVISOR). + let frames = crate::glyphs::monitor_icon_frames(); + let frame_idx = (tick / MONITOR_PULSE_DIVISOR) as usize % frames.len(); + let icon = format!("{} ", frames[frame_idx]); + let label_fg = if buttons.is_some_and(|b| b.watching_hovered) { + theme.text_primary + } else { + theme.gray + }; + let cue_width = (icon.width() + cue.width()).min(area.width as usize) as u16; + let spans = vec![ + Span::styled(icon, Style::default().fg(theme.accent_system)), + Span::styled(cue, Style::default().fg(label_fg)), + ]; + buf.set_line(area.x, area.y, &Line::from(spans), area.width); + // The cue opens the tasks pane on click — only advertise the hit + // area when there are tasks to show (a watcherless parked cue has + // nothing behind it). + return TurnStatusOutput { + watching_cue: (show_buttons && watchers.total() > 0) + .then(|| Rect::new(area.x, area.y, cue_width, 1)), + ..TurnStatusOutput::default() + }; + } return TurnStatusOutput::default(); } @@ -787,9 +802,7 @@ fn render_starting_session( /// completion/events, scheduled `/loop` tasks fire prompts, and background /// subagents inject a completion turn, any of which can start a new turn. /// -/// A parked turn (`parked` — the stopped look while blocked on a sendable -/// wait) suppresses the running-turn chrome entirely: the row shows only when -/// watchers exist, rendering the "… still running" cue. +/// A parked turn always shows the row, watchers or not. /// /// Real MCP progress (`total > 0`) renders as a compact chip in the top status /// bar instead, so it does not affect this row. @@ -801,7 +814,7 @@ pub fn should_show( parked: bool, ) -> bool { if parked { - return watchers.total() > 0; + return true; } !state.is_idle() || drain_blocked @@ -1068,9 +1081,7 @@ mod tests { } #[test] - fn should_show_parked_only_with_watchers() { - // Parked (turn running but rendering the stopped look): the row shows - // only to carry the "… still running" cue — never the running chrome. + fn should_show_parked_always() { assert!(should_show( &AgentState::TurnRunning, false, @@ -1081,7 +1092,7 @@ mod tests { }, true )); - assert!(!should_show( + assert!(should_show( &AgentState::TurnRunning, false, None, @@ -1435,16 +1446,14 @@ mod tests { #[test] fn parked_with_watchers_renders_cue_not_running_chrome() { - // A parked running turn renders the still-running cue — never the busy - // spinner/timers/[stop] chrome (the wait aborts as soon as the user - // types, so that chrome would lie). + // The wait aborts as soon as the user types, so busy chrome would lie. let text = render_parked_with_watchers(Watchers { commands: 2, ..Watchers::default() }); assert!( - text.contains("2 commands still running"), - "parked with bg work must render the still-running cue, got: {text:?}" + text.contains("2 commands still running \u{00b7} send a message to interrupt"), + "parked with bg work must render the interruptible still-running cue, got: {text:?}" ); assert!( !text.contains("Waiting") && !text.contains("[stop]"), @@ -1453,11 +1462,39 @@ mod tests { } #[test] - fn parked_without_watchers_renders_nothing() { + fn parked_without_watchers_renders_waiting_cue() { let text = render_parked_with_watchers(Watchers::default()); assert!( - text.trim().is_empty(), - "parked with no watchers must render nothing, got: {text:?}" + text.contains("waiting \u{00b7} send a message to interrupt"), + "watcherless parked must render the waiting interrupt cue, got: {text:?}" + ); + assert!( + !text.contains("[stop]"), + "watcherless parked must not render the running-turn chrome, got: {text:?}" + ); + } + + #[test] + fn parked_with_held_queue_renders_queued_hint() { + // The queued hint replaces the interrupt copy (Enter = send-now). + let activity = Some(TurnActivity::Waiting(WaitingReason::TasksComplete)); + let mut args = idle_args(Watchers { + commands: 1, + ..Watchers::default() + }); + args.state = &AgentState::TurnRunning; + args.activity = &activity; + args.parked = true; + args.held_queue = 1; + args.held_queue_top_sendable = true; + let text = render_row_text(args, 80); + assert!( + text.contains("1 queued — Enter to send now"), + "parked with a held row must advertise the queued hint, got: {text:?}" + ); + assert!( + !text.contains("send a message to interrupt"), + "queued hint replaces the interrupt copy, got: {text:?}" ); } diff --git a/crates/codegen/xai-grok-pager/src/worktree_cmd/mod.rs b/crates/codegen/xai-grok-pager/src/worktree_cmd/mod.rs index 1e24508..4a2408f 100644 --- a/crates/codegen/xai-grok-pager/src/worktree_cmd/mod.rs +++ b/crates/codegen/xai-grok-pager/src/worktree_cmd/mod.rs @@ -96,6 +96,9 @@ enum WorktreeDbCommand { pub async fn run(args: WorktreeArgs, agent_config: &AgentConfig) -> Result<()> { let cancel = CancellationToken::new(); let spawned = crate::acp::spawn::spawn_grok_shell(agent_config.clone(), &cancel, None).await?; + // Cancel + join on every return path, including the `?` below. + let _agent_guard = + crate::acp::spawn::AgentShutdownGuard::new(cancel.clone(), Some(spawned.thread_handle)); let _init: acp::InitializeResponse = acp_send( acp::InitializeRequest::new(acp::ProtocolVersion::V1) @@ -116,9 +119,7 @@ pub async fn run(args: WorktreeArgs, agent_config: &AgentConfig) -> Result<()> { ) .await?; - let result = dispatch(args.command, &spawned.channel.tx).await; - cancel.cancel(); - result + dispatch(args.command, &spawned.channel.tx).await } async fn dispatch(command: WorktreeCommand, tx: &xai_acp_lib::AcpAgentTx) -> Result<()> { diff --git a/crates/codegen/xai-grok-pager/tests/leader_pty_e2e/campaign_leader_mode_remote_dismiss_on_model_pick.rs b/crates/codegen/xai-grok-pager/tests/leader_pty_e2e/campaign_leader_mode_remote_dismiss_on_model_pick.rs index 65558fd..3c017ae 100644 --- a/crates/codegen/xai-grok-pager/tests/leader_pty_e2e/campaign_leader_mode_remote_dismiss_on_model_pick.rs +++ b/crates/codegen/xai-grok-pager/tests/leader_pty_e2e/campaign_leader_mode_remote_dismiss_on_model_pick.rs @@ -81,13 +81,23 @@ async fn campaign_leader_mode_remote_dismiss_on_model_pick() { let mut recorded = false; 'attempts: for attempt in 0..3 { let mut h = spawn(); - h.wait_for_text(WELCOME_SCREEN_SENTINEL, LEADER_TIMEOUT) - .unwrap_or_else(|_| { - panic!( - "leader-mode welcome never rendered (attempt {attempt})\nscreen:\n{}", - h.screen_contents() - ) - }); + // Cold leader bring-up (leader election plus an unoptimized-binary + // boot) can miss the welcome paint within LEADER_TIMEOUT under + // remote-runner load. The leader outlives this client, so a fresh spawn + // attaches to the now-live leader and paints promptly: retry like a + // missed campaign rather than hard-failing on the first loaded cold + // start, and only panic once all attempts are exhausted. + if h.wait_for_text(WELCOME_SCREEN_SENTINEL, LEADER_TIMEOUT) + .is_err() + { + let screen = h.screen_contents(); + h.quit().expect("clean quit"); + assert!( + attempt < 2, + "leader-mode welcome never rendered after 3 attempts\nscreen:\n{screen}" + ); + continue; + } if !wait_for_model_via_new_sessions(&mut h, CAMPAIGN_MODEL, Duration::from_secs(60)) { // Campaign never applied on this spawn; try a fresh TUI. h.quit().expect("clean quit"); diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/bash_full_output_double_click_fold_pty.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/bash_full_output_double_click_fold_pty.rs index efc416e..2ff0769 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e/bash_full_output_double_click_fold_pty.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/bash_full_output_double_click_fold_pty.rs @@ -2,6 +2,28 @@ #[allow(unused_imports)] use super::common::*; +/// SGR double-click (press/release × 2) at 0-based (row, col). +fn double_click_at(harness: &mut PtyHarness, row: u16, col: u16) { + let dbl = format!( + "{}{}{}{}", + sgr_mouse(0, row, col, 'M'), + sgr_mouse(0, row, col, 'm'), + sgr_mouse(0, row, col, 'M'), + sgr_mouse(0, row, col, 'm'), + ); + harness + .inject_keys(dbl.as_bytes()) + .expect("inject SGR double-click"); +} + +/// Locate `needle` and double-click its first character cell. +fn double_click_text(harness: &mut PtyHarness, needle: &str) { + let screen = harness.screen_contents(); + let (row, col) = locate_screen_text(&screen, needle) + .unwrap_or_else(|| panic!("locate {needle:?}; screen:\n{screen}")); + double_click_at(harness, row, col); +} + /// PTY, against the built binary with real SGR clicks: a finished `!` /// command shows its full output (success and failure), double-click folds /// the block, and a second double-click restores the full output — never @@ -36,23 +58,25 @@ async fn bash_full_output_double_click_fold_pty() { .expect("session ready"); // 1. Success: 12 lines exceed the streaming window; all visible on finish. + // + // Truncated (default first=2, last=3) shows L01,L02 + L10–L12. A middle + // line (L06) appears only after expand-on-finish — do not gate on L01: + // that passes while still truncated and races the L03/L06/L09 asserts. harness .inject_keys(b"! printf 'L%02d\\n' $(seq 1 12)\r") .expect("submit bash-mode command"); harness .wait_for_text("L12", Duration::from_secs(30)) .expect("bash output tail"); - // Live tail can show L06–L12 while L01 is still clipped; wait for - // expand-on-finish before asserting the head is present. harness - .wait_for_text("L01", Duration::from_secs(15)) + .wait_for_text("L06", Duration::from_secs(20)) .unwrap_or_else(|_| { panic!( - "finished ! command must not truncate output (L01 missing)\nscreen:\n{}", + "finished ! command must expand full output (middle L06 missing)\nscreen:\n{}", harness.screen_contents() ) }); - for line in ["L03", "L06", "L09"] { + for line in ["L01", "L03", "L09"] { assert!( harness.contains_text(line), "finished ! command must not truncate output ({line} missing)\nscreen:\n{}", @@ -65,34 +89,22 @@ async fn bash_full_output_double_click_fold_pty() { harness .wait_for_text("Ctrl+e:", Duration::from_secs(10)) .expect("scrollback owns keys"); - let screen = harness.screen_contents(); - let (row, col) = locate_screen_text(&screen, "Run (user)") - .unwrap_or_else(|| panic!("locate ! block header; screen:\n{screen}")); - let dbl = format!( - "{}{}{}{}", - sgr_mouse(0, row, col, 'M'), - sgr_mouse(0, row, col, 'm'), - sgr_mouse(0, row, col, 'M'), - sgr_mouse(0, row, col, 'm'), - ); + double_click_text(&mut harness, "Run (user)"); harness - .inject_keys(dbl.as_bytes()) - .expect("double-click to fold"); - let gone = std::time::Instant::now() + Duration::from_secs(5); - while harness.contains_text("L06") && std::time::Instant::now() < gone { - harness.update(Duration::from_millis(100)); - } - assert!( - !harness.contains_text("L06"), - "double-click must collapse the ! block; got:\n{}", - harness.screen_contents() - ); - harness.update(Duration::from_millis(500)); // let the multi-click window lapse + .wait_for_text_absent("L06", Duration::from_secs(15)) + .unwrap_or_else(|_| { + panic!( + "double-click must collapse the ! block; got:\n{}", + harness.screen_contents() + ) + }); + // MULTI_CLICK_TIMEOUT_MS is 300ms; clear it before the expand gesture so + // the second double-click is not counted as click 3/4 of the first. + harness.update(Duration::from_millis(500)); + // Re-locate: collapse shrinks the block and may move the header on screen. + double_click_text(&mut harness, "Run (user)"); harness - .inject_keys(dbl.as_bytes()) - .expect("double-click to expand"); - harness - .wait_for_text("L06", Duration::from_secs(10)) + .wait_for_text("L06", Duration::from_secs(15)) .unwrap_or_else(|_| { panic!( "double-click must restore the FULL output (middle lines); got:\n{}", @@ -112,7 +124,7 @@ async fn bash_full_output_double_click_fold_pty() { .wait_for_text("E12", Duration::from_secs(30)) .expect("failed bash output tail"); harness - .wait_for_text("E06", Duration::from_secs(10)) + .wait_for_text("E06", Duration::from_secs(20)) .unwrap_or_else(|_| { panic!( "FAILED ! command must show its full output; got:\n{}", diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/common.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/common.rs index 08352e9..b676494 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e/common.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/common.rs @@ -28,10 +28,11 @@ pub(crate) const WELCOME_TIMEOUT: Duration = Duration::from_secs(20); /// session spawn) on the agent's single-threaded runtime, and the client-side /// `acp_send` has no timeout — so under the fully-parallel pty_e2e suite the /// starved agent thread can push this well past the 20s `WELCOME_TIMEOUT` -/// (leaving the "Loading session…" placeholder up). Sized generously for the -/// same contention reason as `WRAP_TIMEOUT`, not because resume is slow when -/// run alone. -pub(crate) const RESUME_TIMEOUT: Duration = Duration::from_secs(60); +/// (leaving the "Loading session…" placeholder up). A prior 60s budget still +/// timed out under CI load with the same stuck-loading signature; match +/// [`WRAP_TIMEOUT`] (120s) for the same contention reason, not because resume +/// is slow when run alone. +pub(crate) const RESUME_TIMEOUT: Duration = Duration::from_secs(120); /// Substring we wait for on the welcome screen. Matches the menu label `"Quit"` /// (`render_welcome_done` / gate menus); case-sensitive, so it does **not** @@ -976,13 +977,16 @@ pub(crate) fn wait_minimal_ready(harness: &mut PtyHarness) { /// Quit minimal cleanly. The prompt is always focused (a bare `q` would type /// into it), so quit is Ctrl+Q pressed twice (it requires confirmation). Falls -/// back to the harness kill path if the chord doesn't take. +/// back to the harness kill path if the chord doesn't take. Give the confirm +/// chord and process exit enough time under suite load so a SIGKILL does not +/// cut off the agent mid-`updates.jsonl` flush (which breaks a subsequent +/// `--continue` resume). pub(crate) fn quit_minimal(harness: &mut PtyHarness) { let _ = harness.inject_keys(b"\x11"); // Ctrl+Q — arms the confirm - harness.update(Duration::from_millis(80)); + harness.update(Duration::from_millis(200)); let _ = harness.inject_keys(b"\x11"); // Ctrl+Q — confirms match harness - .wait_exit_code(Duration::from_secs(5)) + .wait_exit_code(Duration::from_secs(15)) .expect("wait for minimal pager exit") { PtyExitPoll::Running => harness.quit().expect("kill minimal pager after timeout"), diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/empty_enter_sends_top_not_last_of_two.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/empty_enter_sends_top_not_last_of_two.rs index 2fc2053..5a94663 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e/empty_enter_sends_top_not_last_of_two.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/empty_enter_sends_top_not_last_of_two.rs @@ -56,16 +56,27 @@ async fn empty_enter_sends_top_not_last_of_two() { .inject_keys(b"\r") .expect("empty Enter send-now top"); turn_one.release(); - // Alpha (the promoted TOP row) then bravo drain back-to-back. Each - // promoted "❯ …" block and the intermediate TURNTWO reply is scrolled - // above the viewport by the next turn's start-adoption before a 100ms poll - // can observe it, so gating on those transient markers is inherently racy. - // Gate only on the FINAL reply (stable at the viewport head) and prove the - // top-row order + send-now silence via the recorded wire below, which is - // not subject to scrolling. - harness - .wait_for_text("TURNTHREE", Duration::from_secs(90)) - .expect("all queued turns drained through to the final reply"); + // Alpha (the promoted TOP row) then bravo drain back-to-back after the + // completion release. Each promoted "❯ …" block and every reply — + // including the final TURNTHREE — can scroll above the viewport before a + // 100ms poll observes it, so gating on any on-screen marker is inherently + // racy (a flaky observation, not a real failure — same rationale as + // `removed_queued_prompt_never_sent`). Gate on the WIRE instead: wait + // until bravo's request has been sent, which is the authoritative record + // that both queued rows drained in order. Pump the event loop while + // waiting so the queued rows actually promote. + let deadline = std::time::Instant::now() + Duration::from_secs(90); + while !all_user_messages(&content) + .iter() + .any(|u| u.contains("queue-bravo-later")) + { + assert!( + std::time::Instant::now() < deadline, + "queued rows never drained through to the final turn\nscreen:\n{}", + harness.screen_contents() + ); + harness.update(Duration::from_millis(100)); + } tokio::time::timeout(Duration::from_secs(10), turn_two.wait_satisfied()) .await .expect("top queued row expectation satisfied"); diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/endline_park_two_static_markers.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/endline_park_is_markerless.rs similarity index 52% rename from crates/codegen/xai-grok-pager/tests/pty_e2e/endline_park_two_static_markers.rs rename to crates/codegen/xai-grok-pager/tests/pty_e2e/endline_park_is_markerless.rs index f597f7c..b74d066 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e/endline_park_two_static_markers.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/endline_park_is_markerless.rs @@ -1,29 +1,17 @@ -//! PTY: a parked wait produces two static markers — the park pushes a plain -//! "Worked for X" line (the still-running work shows on the status row's -//! "… still running" cue, not in the transcript) and the turn that follows ends -//! with its own marker below. A prompt typed mid-park is cancel-and-send: -//! the shell silently cancels the parked turn (no "Turn cancelled by user" -//! marker) and runs the message as its OWN next turn, whose completion pushes -//! the second marker; the park line is never edited, so the transcript holds -//! BOTH markers with the park text intact, in order. -//! -//! Wire journey, fully flag-file driven — no timing windows: the model -//! backgrounds a flag-gated command, then runs a flag-gated foreground hold -//! while the test extracts the runtime task id from the request bodies -//! (`` envelope; a UUID minted by the terminal actor, so it cannot -//! be scripted statically) and enqueues the blocking -//! `get_command_or_subagent_output(timeout)` on the real id — the pager -//! parks. Typing mid-park cancels-and-sends and the fixed-text reply ends -//! the new turn with the second, final marker. +//! PTY, fully flag-file driven: the model backgrounds a flag-gated command, +//! the test extracts the runtime task id and enqueues a blocking +//! `get_command_or_subagent_output` on it (park), then types mid-park +//! (cancel-and-send). Asserts exactly ONE "Worked for" marker — the new +//! turn's — with no park row and no "Turn cancelled by user" marker. #[allow(unused_imports)] use super::common::*; #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"] -async fn endline_park_two_static_markers() { +async fn endline_park_is_markerless() { let content = ContentController::start().await.expect("start content"); - // Gates the background command the markers count (released at the end). + // Gates the background command the watching cue counts (released at the end). let park_flag = content.home().join("endline_park_flag"); // Gates the id-extraction hold: created once the wait script is enqueued. let id_ready_flag = content.home().join("endline_id_ready_flag"); @@ -32,8 +20,7 @@ async fn endline_park_two_static_markers() { format!("while [ ! -e {} ]; do /bin/sleep 0.2; done", flag.display()) }; - // Tool call 1: a flag-gated background command — the work the watching - // cue counts ("1 command still running"). + // Tool call 1: the flag-gated background command the watching cue counts. let bg_args = json!({ "command": gated_loop(&park_flag), "description": "flag-gated command", @@ -43,9 +30,8 @@ async fn endline_park_two_static_markers() { let _background_turn = expect_tool_turn(&content, "call_endline_bg", "run_terminal_command", bg_args); - // Tool call 2: the flag-gated foreground hold — the turn idles here (no - // deadline) until the test has extracted the task id and enqueued the - // wait script. + // Tool call 2: a flag-gated foreground hold keeps the turn open until the + // test has extracted the task id and enqueued the wait script. let id_hold_args = json!({ "command": gated_loop(&id_ready_flag), "description": "hold for id extraction" @@ -97,8 +83,7 @@ async fn endline_park_two_static_markers() { ) }); - // Tool call 3: block on the REAL task — the interruptible wait the pager - // parks on (600s survives the wait cap; the send-now cancel aborts it). + // Tool call 3: block on the real task id (600s survives the wait cap). let wait_args = json!({ "task_ids": [task_id], "timeout_ms": 600_000 @@ -114,39 +99,29 @@ async fn endline_park_two_static_markers() { // Everything downstream is scripted — let the id-extraction hold finish. std::fs::write(&id_ready_flag, b"ready").expect("release id-extraction hold"); - // Park: the first static marker reads as a plain completion; the - // still-running work shows on the status row's watching cue instead. harness - .wait_for_text("Worked for", Duration::from_secs(90)) + .wait_for_text("1 command still running", Duration::from_secs(90)) .unwrap_or_else(|_| { panic!( - "parked marker never appeared; screen:\n{}\n--- non-system messages ---\n{}", + "parked watching cue never appeared; screen:\n{}\n--- non-system messages ---\n{}", harness.screen_contents(), dump_non_system_messages(&content.request_bodies()) ) }); harness - .wait_for_text("1 command still running", Duration::from_secs(30)) + .wait_for_text("send a message to interrupt", Duration::from_secs(30)) .unwrap_or_else(|_| { panic!( - "parked watching cue never appeared; screen:\n{}", + "parked interrupt cue never appeared; screen:\n{}", harness.screen_contents() ) }); - // The status row's cue is the only "still running" on screen — the - // parked marker line itself stays a plain "Worked for X". let screen = harness.screen_contents(); assert!( - screen - .lines() - .filter(|l| l.contains("Worked for")) - .all(|l| !l.contains("still running")), - "the parked marker carries no still-running suffix; screen:\n{screen}" + !screen.contains("Worked for"), + "a park must write no marker; screen:\n{screen}" ); - // Type mid-park: Enter is cancel-and-send (the wait makes it a sendable - // parked turn) — the parked turn is cancelled silently and the message - // runs as its own next turn. harness .inject_keys(b"hurry up please") .expect("type mid-park"); @@ -162,47 +137,29 @@ async fn endline_park_two_static_markers() { ) }); - // The turn-start adoption scrolls the promoted "❯ hurry up please" block - // to the viewport top, pushing the park marker above the screen. Scroll - // the transcript back to its head so both markers are inspectable. + // Scroll to the transcript head so the whole journey is on one screen. harness.inject_keys(b"\t").expect("focus scrollback (tab)"); harness.update(Duration::from_millis(300)); harness.inject_keys(b"g").expect("goto transcript top"); - // Two static markers: the park line unchanged above the promoted prompt - // and the new turn's final marker below it — both plain "Worked for X" - // lines (no still-running suffix; the bg command is still gated, so the - // status row legitimately shows "1 command still running" — scope the - // suffix check to the marker lines) — with NO cancelled marker anywhere - // (silent send-now cancel). - let two_markers = wait_until(Duration::from_secs(90), || { + let single_marker = wait_until(Duration::from_secs(90), || { harness.update(Duration::from_millis(100)); let screen = harness.screen_contents(); - // Positional: park marker ABOVE the promoted prompt ABOVE the final - // marker (screen text is row-major), both markers intact. - screen.matches("Worked for").count() == 2 - && screen - .lines() - .filter(|l| l.contains("Worked for")) - .all(|l| !l.contains("still running")) + screen.matches("Worked for").count() == 1 && !screen.contains("Turn cancelled by user") && matches!( - ( - screen.find("Worked for"), - screen.find("hurry up please"), - screen.rfind("Worked for"), - ), - (Some(park), Some(prompt), Some(fin)) if park < prompt && prompt < fin + (screen.find("hurry up please"), screen.find("Worked for")), + (Some(prompt), Some(fin)) if prompt < fin ) }); assert!( - two_markers, - "expected park marker, promoted prompt, then the fresh final marker in order \ - (and no cancelled marker); screen:\n{}", + single_marker, + "expected the promoted prompt then ONE final marker (no park marker, \ + no cancelled marker); screen:\n{}", harness.screen_contents() ); - write_cast_if_requested(&harness, "endline_park_two_static_markers.cast"); + write_cast_if_requested(&harness, "endline_park_is_markerless.cast"); // Release the flag-gated command so nothing outlives the harness teardown. std::fs::write(&park_flag, b"done").expect("release flag"); diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/endline_wakeups_are_markerless.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/endline_wakeups_close_with_markers.rs similarity index 71% rename from crates/codegen/xai-grok-pager/tests/pty_e2e/endline_wakeups_are_markerless.rs rename to crates/codegen/xai-grok-pager/tests/pty_e2e/endline_wakeups_close_with_markers.rs index a453f56..9bf02d9 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e/endline_wakeups_are_markerless.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/endline_wakeups_close_with_markers.rs @@ -1,13 +1,7 @@ -//! PTY: auto-wake turns close MARKERLESS — a turn ends with three flag-gated -//! background commands running (one plain "Worked for" marker), and each -//! released flag lands a completion chip and the auto-wake response with NO -//! wake-end marker after it, while every earlier line stays unchanged above -//! (nothing mutates). The persistent "N commands still running" status row -//! above the prompt counts the remaining work down between wakes and -//! disappears once nothing is left; markers never carry that copy as a suffix. -//! -//! Positional chain asserted at the end: marker < chip < wake reply < chip < -//! reply < chip < reply — exactly ONE "Worked for" total (the user turn's). +//! PTY: a turn ends with three flag-gated background commands running; each +//! released flag lands a completion chip, the auto-wake reply, and that wake +//! turn's own closing marker. Asserts the positional chain marker < chip < +//! reply per round — FOUR "Worked for" total — and the cue counting 3→2→1→gone. #[allow(unused_imports)] use super::common::*; @@ -23,7 +17,7 @@ const ROWS: u16 = 70; #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"] -async fn endline_wakeups_are_markerless() { +async fn endline_wakeups_close_with_markers() { let content = ContentController::start().await.expect("start content"); let flags: Vec = (0..TASKS) .map(|i| content.home().join(format!("endline_status_flag_{i}"))) @@ -78,8 +72,6 @@ async fn endline_wakeups_are_markerless() { .inject_keys(format!("{PROMPT}\r").as_bytes()) .expect("submit prompt"); - // The turn ends with all three commands running: one plain marker, and - // the status row's watching cue carrying the count. harness .wait_for_text("STATUS_TURN_SETTLED", Duration::from_secs(60)) .unwrap_or_else(|_| { @@ -106,57 +98,49 @@ async fn endline_wakeups_are_markerless() { ) }); - // Release flag 0: chip → wake reply, NO wake marker; the watching cue - // counts down to 2 while the one user-turn marker stays intact above - // (screen text is row-major, so find offsets order the lines). std::fs::write(&flags[0], b"done").expect("release flag 0"); let wake_one = wait_until(Duration::from_secs(45), || { harness.update(Duration::from_millis(100)); let screen = harness.screen_contents(); screen.contains("WAKE_REPLY_ONE") - && screen.matches("Worked for").count() == 1 + && screen.matches("Worked for").count() == 2 && screen.contains("2 commands still running") }); assert!( wake_one, - "expected chip → wake reply with no wake marker, watching cue at 2; screen:\n{}", + "expected chip → wake reply → the wake's closing marker, watching cue at 2; screen:\n{}", harness.screen_contents() ); - // Release flag 1: the second wake chain joins below; cue counts 1. std::fs::write(&flags[1], b"done").expect("release flag 1"); let wake_two = wait_until(Duration::from_secs(45), || { harness.update(Duration::from_millis(100)); let screen = harness.screen_contents(); screen.contains("WAKE_REPLY_TWO") - && screen.matches("Worked for").count() == 1 + && screen.matches("Worked for").count() == 3 && screen.contains("1 command still running") }); assert!( wake_two, - "expected the second markerless wake chain below the earlier lines; screen:\n{}", + "expected the second wake chain (with its marker) below the earlier lines; screen:\n{}", harness.screen_contents() ); - // Release flag 2: zero left — still exactly one marker, and the watching - // cue disappears entirely (its "still running" copy leaves the screen). std::fs::write(&flags[2], b"done").expect("release flag 2"); let wake_three = wait_until(Duration::from_secs(45), || { harness.update(Duration::from_millis(100)); let screen = harness.screen_contents(); screen.contains("WAKE_REPLY_THREE") - && screen.matches("Worked for").count() == 1 + && screen.matches("Worked for").count() == 4 && !screen.contains("still running") }); assert!( wake_three, - "the last wake must stay markerless and retire the watching cue; screen:\n{}", + "the last chatty wake must close with its marker and retire the watching cue; screen:\n{}", harness.screen_contents() ); - // Full chain, positional: marker < chip < reply < chip < reply < chip < - // reply — one marker total, and no marker carries a "still running" - // suffix (that copy belongs to the status row's cue, retired above). + // Screen text is row-major, so match offsets order the lines. let screen = harness.screen_contents(); let chips: Vec = screen .match_indices("Task completed") @@ -170,8 +154,8 @@ async fn endline_wakeups_are_markerless() { let markers: Vec = screen.match_indices("Worked for").map(|(i, _)| i).collect(); assert_eq!( markers.len(), - 1, - "exactly one marker — the user turn's; screen:\n{screen}" + 4, + "four markers — the user turn's plus one per chatty wake; screen:\n{screen}" ); let w1 = screen.find("WAKE_REPLY_ONE").expect("wake reply 1"); let w2 = screen.find("WAKE_REPLY_TWO").expect("wake reply 2"); @@ -179,10 +163,13 @@ async fn endline_wakeups_are_markerless() { assert!( markers[0] < chips[0] && chips[0] < w1 - && w1 < chips[1] + && w1 < markers[1] + && markers[1] < chips[1] && chips[1] < w2 - && w2 < chips[2] - && chips[2] < w3, + && w2 < markers[2] + && markers[2] < chips[2] + && chips[2] < w3 + && w3 < markers[3], "chain out of order; screen:\n{screen}" ); assert!( @@ -193,5 +180,5 @@ async fn endline_wakeups_are_markerless() { "markers must never carry a still-running suffix; screen:\n{screen}" ); - write_cast_if_requested(&harness, "endline_wakeups_are_markerless.cast"); + write_cast_if_requested(&harness, "endline_wakeups_close_with_markers.cast"); } diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/minimal/minimal_continue_reprints_transcript.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/minimal/minimal_continue_reprints_transcript.rs index 7f4c468..3cf32d9 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e/minimal/minimal_continue_reprints_transcript.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/minimal/minimal_continue_reprints_transcript.rs @@ -26,6 +26,14 @@ async fn minimal_continue_reprints_transcript() { first .wait_for_full_text(&turn_sentinel(1), Duration::from_secs(30)) .expect("turn 1 committed to scrollback"); + // Idle before quit so the agent finishes turn completion + updates.jsonl + // flush. Quitting mid-finalize under suite load left `--continue` loading a + // session with the user message but no assistant payload (resume then + // shows "Loading session…" / empty chrome past RESUME_TIMEOUT). + first + .wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(15)) + .expect("turn 1 returned to idle before quit"); + first.update(Duration::from_millis(300)); quit_minimal(&mut first); // Resume the same session. The transcript is reprinted into native diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/queued_message_renders_once_not_twice.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/queued_message_renders_once_not_twice.rs index fd65600..03c139b 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e/queued_message_renders_once_not_twice.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/queued_message_renders_once_not_twice.rs @@ -1,14 +1,7 @@ -//! PTY: the core "queued message appears 2x" regression. A message queued -//! mid-turn HOLDS through the turn's sendable wait, rendering exactly once -//! as a queue row (id/kind+text reconciled — no optimistic-echo duplicate), -//! suppressing the park marker, and advertising send-now on the status row; -//! after the wait returns and the turn ends it drains as its own turn and -//! renders exactly once as a "❯ " block. -//! -//! Flag-file driven like `endline_park_two_static_markers`: background a -//! flag-gated command, hold the turn on a flag-gated foreground command -//! (queueing happens in this window), then block on -//! `get_command_or_subagent_output(timeout_ms: 600000)`. +//! PTY, flag-file driven like `endline_park_is_markerless`: the "queued +//! message appears 2x" regression. A message queued mid-turn holds through +//! the turn's sendable wait, then drains as its own turn — asserting it +//! renders exactly once as a queue row and exactly once as a "❯ " block. #[allow(unused_imports)] use super::common::*; @@ -112,7 +105,7 @@ async fn queued_message_renders_once_not_twice() { // The wait parks the turn with the row HELD: the status row explains the // hold ("1 queued — Enter to send now"; the top row is a sendable server - // row), the park marker is suppressed, and the row renders exactly once. + // row), the park writes no marker, and the row renders exactly once. harness .wait_for_text("1 queued \u{2014} Enter to send now", Duration::from_secs(60)) .unwrap_or_else(|_| { @@ -124,7 +117,7 @@ async fn queued_message_renders_once_not_twice() { }); assert!( !harness.contains_text("Worked for"), - "held queued rows must suppress the park marker\nscreen:\n{}", + "a park writes no marker\nscreen:\n{}", harness.screen_contents() ); assert_eq!( diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/reparked_wait_repushes_buried_marker.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/reparked_wait_stays_markerless.rs similarity index 65% rename from crates/codegen/xai-grok-pager/tests/pty_e2e/reparked_wait_repushes_buried_marker.rs rename to crates/codegen/xai-grok-pager/tests/pty_e2e/reparked_wait_stays_markerless.rs index 3490dfd..8bde850 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e/reparked_wait_repushes_buried_marker.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/reparked_wait_stays_markerless.rs @@ -1,14 +1,7 @@ -//! PTY: a re-parked wait (new parent output between parks) pushes a fresh -//! parked marker for the new park episode, so the transcript keeps a -//! boundary where each park began, while the persistent "… still running" -//! status row explains the still-running background work. -//! -//! Wire journey, flag-file driven like `endline_park_two_static_markers`: -//! background a flag-gated command, hold on a flag-gated foreground command -//! while the runtime task id is extracted, then script three more rounds on -//! the real id — a short wait (`timeout_ms: 4000`) that expires with the -//! task still running (park #1 + marker), a quick foreground echo, and a -//! long wait (park #2: chrome hidden and a fresh marker for the new episode). +//! PTY, flag-file driven like `endline_park_is_markerless`: a short wait that +//! expires (park #1), foreground work between the parks, then a long wait on +//! the same still-running task (park #2). Asserts neither park writes a +//! transcript row and only the real turn end pushes the single "Worked for X". #[allow(unused_imports)] use super::common::*; @@ -28,7 +21,7 @@ const FINAL: &str = "REPARK_FINAL_ANSWER"; #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"] -async fn reparked_wait_repushes_buried_marker() { +async fn reparked_wait_stays_markerless() { let content = ContentController::start().await.expect("start content"); // Gates the background command both waits block on (released at the end). let park_flag = content.home().join("repark_flag"); @@ -99,8 +92,7 @@ async fn reparked_wait_repushes_buried_marker() { ) }); - // Tool call 3 — park #1: a short wait that expires with the task still - // running. + // Tool call 3 — park #1: a short wait that expires with the task still running. let short_wait_args = json!({ "task_ids": [task_id], "timeout_ms": 4_000 @@ -113,8 +105,7 @@ async fn reparked_wait_repushes_buried_marker() { short_wait_args, ); - // Tool call 4: foreground work between the parks (`MIDWORK` is the - // on-screen sentinel). + // Tool call 4: foreground work between the parks (`MIDWORK` is the on-screen sentinel). let midwork_args = json!({ "command": "echo repark-midwork-done", "description": MIDWORK @@ -143,27 +134,20 @@ async fn reparked_wait_repushes_buried_marker() { // Everything downstream is scripted — release the id-extraction hold. std::fs::write(&id_ready_flag, b"ready").expect("release id-extraction hold"); - // Park #1 marker (plain "Worked for X" — no still-running suffix). - harness - .wait_for_text("Worked for", Duration::from_secs(90)) - .unwrap_or_else(|_| { - panic!( - "park #1 marker never appeared; screen:\n{}\n--- non-system messages ---\n{}", - harness.screen_contents(), - dump_non_system_messages(&content.request_bodies()) - ) - }); - // The parked status row carries the still-running story instead. - harness - .wait_for_text("1 command still running", Duration::from_secs(30)) - .unwrap_or_else(|_| { - panic!( - "parked watching cue never appeared; screen:\n{}", - harness.screen_contents() - ) - }); + let park_one = wait_until(Duration::from_secs(90), || { + harness.update(Duration::from_millis(100)); + let screen = harness.screen_contents(); + screen.contains("1 command still running") + && screen.contains("send a message to interrupt") + && !screen.contains("Worked for") + }); + assert!( + park_one, + "park #1 must show the parked cue with no marker; screen:\n{}\n--- non-system messages ---\n{}", + harness.screen_contents(), + dump_non_system_messages(&content.request_bodies()) + ); - // The short wait expires and the same turn resumes. harness .wait_for_text(MIDWORK, Duration::from_secs(60)) .unwrap_or_else(|_| { @@ -174,7 +158,6 @@ async fn reparked_wait_repushes_buried_marker() { ) }); - // Park #2: the running chrome drops again. let chrome_hidden = wait_until(Duration::from_secs(30), || { harness.update(Duration::from_millis(100)); !harness.contains_text(CANCEL_HINT) @@ -185,44 +168,21 @@ async fn reparked_wait_repushes_buried_marker() { harness.screen_contents() ); - // Park #2 pushes a second marker below the between-parks content (a new - // park episode after new parent output). - let repushed = wait_until(Duration::from_secs(30), || { + let park_two = wait_until(Duration::from_secs(30), || { harness.update(Duration::from_millis(100)); - harness.screen_contents().matches("Worked for").count() == 2 + let screen = harness.screen_contents(); + screen.contains("1 command still running") && !screen.contains("Worked for") }); assert!( - repushed, - "re-park after buried marker must push a fresh marker; screen:\n{}", + park_two, + "park #2 must stay markerless with the parked cue up; screen:\n{}", harness.screen_contents() ); let screen = harness.screen_contents(); - - // Screen text is row-major: marker, content, re-pushed marker in order. - let first_marker = screen.find("Worked for").expect("first marker"); + // U+2800–U+28FF = the braille spinner glyphs. let midwork_at = screen .rfind(MIDWORK) .expect("between-parks content on screen"); - let second_marker = screen.rfind("Worked for").expect("re-pushed marker"); - assert!( - first_marker < midwork_at && midwork_at < second_marker, - "expected marker, content, then the re-pushed marker in order; screen:\n{screen}" - ); - // The still-running story lives in the status row, not the transcript: - // no "Worked for" line carries the suffix (line-scoped like the sibling - // suites — other surfaces may legitimately use the phrase). - assert!( - screen - .lines() - .filter(|l| l.contains("Worked for")) - .all(|l| !l.contains("still running")), - "no marker line may carry the still-running suffix; screen:\n{screen}" - ); - assert!( - screen.contains("1 command still running"), - "the parked status row keeps the still-running cue during park #2; screen:\n{screen}" - ); - // The parked look still hides spinner and chrome. let below_midwork = &screen[midwork_at..]; assert!( !below_midwork @@ -235,10 +195,9 @@ async fn reparked_wait_repushes_buried_marker() { "parked look keeps the running chrome hidden during park #2; screen:\n{screen}" ); - eprintln!("── re-park with buried marker: tail explains the park ──\n{screen}\n── end ──"); + eprintln!("── re-park stays markerless: status cue explains the park ──\n{screen}\n── end ──"); - // Liveness: releasing the flag completes the wait and the same turn - // streams the final answer. + // Releasing the flag completes the wait and lets the turn finish. std::fs::write(&park_flag, b"done").expect("release flag"); harness .wait_for_text(FINAL, Duration::from_secs(90)) @@ -253,11 +212,20 @@ async fn reparked_wait_repushes_buried_marker() { harness .wait_for_turn_idle(Duration::from_secs(15)) .expect("turn idle"); + let one_final_marker = wait_until(Duration::from_secs(30), || { + harness.update(Duration::from_millis(100)); + harness.screen_contents().matches("Worked for").count() == 1 + }); + assert!( + one_final_marker, + "exactly one marker — the real turn end's; screen:\n{}", + harness.screen_contents() + ); assert!( !harness.contains_text("panicked"), "pager panicked\nscreen:\n{}", harness.screen_contents() ); - write_cast_if_requested(&harness, "reparked_wait_repushes_buried_marker.cast"); + write_cast_if_requested(&harness, "reparked_wait_stays_markerless.cast"); harness.quit().expect("clean quit"); } diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/spinner_reappears_after_wait_resumes.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/spinner_reappears_after_wait_resumes.rs index 20156a4..2505053 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e/spinner_reappears_after_wait_resumes.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/spinner_reappears_after_wait_resumes.rs @@ -1,14 +1,7 @@ -//! PTY: the parked look is not sticky — when a parked sendable wait RETURNS -//! and the model resumes streaming in the SAME turn, the running chrome -//! (turn-status row + cancel keybar) must come back while the continuation -//! streams (regression: stale tracker waits kept the idle look after resume). -//! -//! Flag-file driven like `endline_park_two_static_markers`: background a -//! flag-gated command, hold the turn on a flag-gated foreground command while -//! the runtime task id is extracted, then block on -//! `get_command_or_subagent_output(timeout_ms: 600000)` — the pager parks. -//! Releasing the flag completes the task, the wait returns, and the scripted -//! slow continuation streams in the same turn. +//! PTY, flag-file driven like `endline_park_is_markerless`: the pager parks +//! on a blocking wait; releasing the flag completes the task and a slow +//! continuation streams in the SAME turn. Asserts the running chrome returns +//! (regression: stale tracker waits kept the idle look after resume). #[allow(unused_imports)] use super::common::*; @@ -105,23 +98,23 @@ async fn spinner_reappears_after_wait_resumes() { std::fs::write(&id_ready_flag, b"ready").expect("release id-extraction hold"); - // Parked look: the plain marker renders, the "… still running" cue takes - // the status row, and the running chrome (cancel keybar) drops — the + // Parked look: the parked cue takes the status row (parks write no + // transcript row) and the running chrome (cancel keybar) drops — the // session reads as stopped. harness - .wait_for_text("Worked for", Duration::from_secs(60)) + .wait_for_text("1 command still running", Duration::from_secs(60)) .unwrap_or_else(|_| { panic!( - "parked marker never appeared; screen:\n{}\n--- non-system messages ---\n{}", + "parked watching cue never appeared; screen:\n{}\n--- non-system messages ---\n{}", harness.screen_contents(), dump_non_system_messages(&content.request_bodies()) ) }); harness - .wait_for_text("1 command still running", Duration::from_secs(30)) + .wait_for_text("send a message to interrupt", Duration::from_secs(30)) .unwrap_or_else(|_| { panic!( - "parked watching cue never appeared; screen:\n{}", + "parked interrupt cue never appeared; screen:\n{}", harness.screen_contents() ) }); diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e_persistence.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e_persistence.rs index 220fd03..6ab0a77 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e_persistence.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e_persistence.rs @@ -13,14 +13,14 @@ mod common; mod background_task_reaped_on_quit; #[path = "pty_e2e/continue_resumes_session_with_history.rs"] mod continue_resumes_session_with_history; -#[path = "pty_e2e/endline_park_two_static_markers.rs"] -mod endline_park_two_static_markers; -#[path = "pty_e2e/endline_wakeups_are_markerless.rs"] -mod endline_wakeups_are_markerless; +#[path = "pty_e2e/endline_park_is_markerless.rs"] +mod endline_park_is_markerless; +#[path = "pty_e2e/endline_wakeups_close_with_markers.rs"] +mod endline_wakeups_close_with_markers; #[path = "pty_e2e/rename_title_shows_in_prompt_border.rs"] mod rename_title_shows_in_prompt_border; -#[path = "pty_e2e/reparked_wait_repushes_buried_marker.rs"] -mod reparked_wait_repushes_buried_marker; +#[path = "pty_e2e/reparked_wait_stays_markerless.rs"] +mod reparked_wait_stays_markerless; #[path = "pty_e2e/spinner_reappears_after_wait_resumes.rs"] mod spinner_reappears_after_wait_resumes; #[path = "pty_e2e/storage_upload_parks_on_401_and_drains_after_recovery.rs"] diff --git a/crates/codegen/xai-grok-shell-base/src/util/mod.rs b/crates/codegen/xai-grok-shell-base/src/util/mod.rs index cbb18ee..fac61d9 100644 --- a/crates/codegen/xai-grok-shell-base/src/util/mod.rs +++ b/crates/codegen/xai-grok-shell-base/src/util/mod.rs @@ -53,6 +53,15 @@ fn matches_trusted_base_url(candidate: &str, trusted_base: &str) -> bool { && candidate.port_or_known_default() == trusted.port_or_known_default() && path_matches } +/// Production cli-chat-proxy base only (compiled-in constant). +/// +/// Unlike [`is_cli_chat_proxy_url`], this rejects loopback and staging/dev hosts. +/// Used for security-sensitive remote kill-switches that must not become env +/// toggles via `GROK_CLI_CHAT_PROXY_BASE_URL` (or similar) pointing at an +/// attacker-controlled origin. +pub fn is_prod_cli_chat_proxy_url(url: &str) -> bool { + matches_trusted_base_url(url, crate::env::PROD_CLI_CHAT_PROXY_BASE_URL) +} /// True for cli-chat-proxy URLs (production, plus local-dev hosts when the /// optional non-production feature is enabled). When that feature is on, /// runtime env overrides can extend this trust set. Loopback is always diff --git a/crates/codegen/xai-grok-shell/CHANGELOG.md b/crates/codegen/xai-grok-shell/CHANGELOG.md index 8318cc2..966ffc4 100644 --- a/crates/codegen/xai-grok-shell/CHANGELOG.md +++ b/crates/codegen/xai-grok-shell/CHANGELOG.md @@ -1,5 +1,55 @@ # Changelog +# 0.2.112 — 2026-07-24 + +## Breaking Changes + +- **CLI version policy** now has separate soft update floors/ceilings and hard startup requirements. + +## Features + +- **New /tutorial slash command** opens an opt-in nine-topic onboarding tour of Grok. +- **New tool_overrides option** lets you set date cutoffs and domain allowlists for the agent's built-in search tools. +- **New toolOverrides option** lets you set date cutoffs and domain allowlists for the agent's built-in search tools. +- **New config options** let you add query parameters or environment-backed headers to custom model providers and control which variables reach shell tools. +- **Terminal and environment fixes** are now consolidated under the `/doctor` command with clearer guidance. +- **Marketplace add** now rejects non-git URLs at add time instead of failing later. +- **Slash commands** can now show optional bracket tags (e.g. [new]) via config or remote settings. +- **Queued prompts** now offer an [edit] mouse button alongside Send now and cancel. +- **Voice shortcut** toggle in settings can disable the Ctrl+Space/F8 keybind without disabling voice entirely. +- **Image edit** can now use a remotely configured model slug instead of the hardcoded default. +- **`grok doctor fix`** can now repair common tmux clipboard and passthrough problems. +- **Per-provider auth helpers** now work on Windows and can run from a configurable working directory. +- **/resume** now shows only native Grok sessions by default and shows a hint when external sessions are hidden. +- **`grok --resume`** can now resume a session by its title as well as by ID. +- **Workflows overlay** now shows live per-agent progress and automatically follows the active phase. +- **Workflow runs** that failed can now be resumed; scratch file limits were also increased. +- **Hooks** can now be defined in config.toml in addition to JSON files. +- **Clicking** the "still running" status now opens the tasks pane. + +## Bug Fixes + +- **File attachments** now appear correctly when resuming or replaying conversations. +- **Terminal output** from remote clients is now recorded so read-file hints and monitors function correctly. +- **Background shell commands** now correctly report their real exit codes instead of always showing -1. +- **Marketplace source refreshes** no longer hang the TUI or trap you in the extensions modal. +- **Background task tray** now correctly clears killed tasks and keeps task descriptions after reconnect. +- **Dashboard overlay** now correctly returns after forking a dashboard-attached session. +- **Linux voice dictation** now works on PipeWire versions before 1.6. +- **Fork** from a rewound session now copies the correct live-branch history. +- **Account pane** now shows name and email even after the access token expires. +- **Voice mode** now lets you edit already-dictated text without closing the microphone. +- **Fixed startup hangs** on Linux after concurrent launches or rapid restarts. +- **MCP tools** now appear without restart after enrolling or updating a managed service. +- **Plugin subagents** now see the same MCP tools as the parent session. +- **Copy confirmations** now show shorter messages when the clipboard succeeds. +- **Repeated identical tool calls** now end the turn silently instead of showing a stop banner. +- **Web search** now defaults to grok-4.5. +- **Voice dictation** text is no longer dropped when pressing Enter to send. +- **Bash mode** (`!`) now shows yellow prefix and action label in minimal mode. +- **Parked turns** no longer spam duplicate "Worked for" markers in the transcript. + + # 0.2.111 — 2026-07-22 ## Features @@ -10,6 +60,7 @@ ## Bug Fixes +- **Plugin subagents** now inherit the parent session’s connected MCP servers (default `mcpInheritance: all`), so `search_tool` / `use_tool` work the same as for local agents. Plugin agents still cannot declare their own MCP servers, hooks, or elevated permission modes. - **`!cmd` commands** now allow up to one hour before timing out. - **npm package** now installs the native binary under `$GROK_HOME/bin` (honoring the same override as the Rust CLI). - **Startup warnings** now point to `/doctor` for details and fixes. diff --git a/crates/codegen/xai-grok-shell/Cargo.toml b/crates/codegen/xai-grok-shell/Cargo.toml index fce5aa6..57fba20 100644 --- a/crates/codegen/xai-grok-shell/Cargo.toml +++ b/crates/codegen/xai-grok-shell/Cargo.toml @@ -1,7 +1,7 @@ [package] license = "Apache-2.0" name = "xai-grok-shell" -version = "0.2.111" +version = "0.2.112" edition.workspace = true [features] @@ -212,6 +212,10 @@ workspace = true name = "session_list" harness = false +[[bench]] +name = "fork_copy" +harness = false + [lints] workspace = true [build-dependencies] diff --git a/crates/codegen/xai-grok-shell/README.md b/crates/codegen/xai-grok-shell/README.md index 128d171..0147365 100644 --- a/crates/codegen/xai-grok-shell/README.md +++ b/crates/codegen/xai-grok-shell/README.md @@ -1727,6 +1727,32 @@ Grok discovers hooks from `.grok/hooks/` in the project directory. Manage them w /hooks-add # add a custom hook file or directory ``` +### Hooks in config files + +Hooks can also be defined directly in the config layers, so they can be +distributed with your other configuration instead of as separate JSON files. Add +a `[[hooks.]]` table to `config.toml` (your own), `managed_config.toml`, or +`requirements.toml`: + +```toml +[[hooks.PreToolUse]] +matcher = "Bash|Write|Edit" + [[hooks.PreToolUse.hooks]] + type = "command" + command = "/opt/guard/pretooluse.sh" # use an absolute path + timeout = 10 +``` + +The schema matches the JSON `hooks` object used in hook files. Hooks are read from +every layer and combined additively: a lower-priority layer can add hooks but +never removes or replaces another layer's block. Each hook's `/hooks-list` name is +prefixed with the layer it came from (for example `managed:` or +`requirements/user:`). + +Config-layer hooks are convenience distribution, not an enforcement boundary: on +an unmanaged device a user can still edit these files. Tamper-resistant, +admin-enforced hooks are tracked separately. + --- ## Custom Models diff --git a/crates/codegen/xai-grok-shell/benches/fork_copy.rs b/crates/codegen/xai-grok-shell/benches/fork_copy.rs new file mode 100644 index 0000000..e0cfe9b --- /dev/null +++ b/crates/codegen/xai-grok-shell/benches/fork_copy.rs @@ -0,0 +1,139 @@ +//! Fork-path benchmark and profiling workbench. +//! +//! Synthesizes a session whose `updates.jsonl` matches a configurable target +//! size (realistic mixed update shapes: user/agent chunks, tool calls with +//! large results), then measures `StorageAdapter::copy_session_data` — the +//! path that materializes the whole file and produced multi-GB RSS spikes on +//! large production sessions. Also the substrate for allocation/CPU profiling +//! (`cargo flamegraph --bench fork_copy`, dhat) and future peak-RSS bounds. +//! +//! Run: `cargo bench -p xai-grok-shell --bench fork_copy` +//! Size override: `FORK_BENCH_MB=64 cargo bench ...` (default 16 MB). + +use std::hint::black_box; +use std::time::Duration; + +use acp::{ContentBlock, ContentChunk, TextContent}; +use agent_client_protocol as acp; +use criterion::{ + BenchmarkId, Criterion, SamplingMode, Throughput, criterion_group, criterion_main, +}; +use tempfile::TempDir; +use xai_grok_shell::session::info::Info; +use xai_grok_shell::session::storage::{ + CopySessionOptions, JsonlStorageAdapter, SessionUpdate, StorageAdapter, +}; + +/// One synthetic "turn": a user chunk, agent chunks, and a bulky tool result, +/// so line-size distribution and parse cost resemble production sessions. +fn turn_updates(info: &Info, turn: usize) -> Vec { + let text = |s: String| ContentChunk::new(ContentBlock::Text(TextContent::new(s))); + let notify = + |u| SessionUpdate::Acp(Box::new(acp::SessionNotification::new(info.id.clone(), u))); + let mut updates = vec![notify(acp::SessionUpdate::UserMessageChunk(text(format!( + "prompt {turn}: check the build and summarize failures" + ))))]; + for i in 0..8 { + updates.push(notify(acp::SessionUpdate::AgentMessageChunk(text(format!( + "agent chunk {turn}/{i}: analyzing module {i} for regressions and drafting a fix plan" + ))))); + } + // ~4 KB tool-result payload: the dominant byte source in real sessions. + updates.push(notify(acp::SessionUpdate::AgentMessageChunk(text( + format!("tool result {turn}: {}", "x".repeat(4096)), + )))); + updates +} + +/// Build a session dir whose `updates.jsonl` is at least `target_bytes`. +fn synthesize_session(root: &TempDir, target_bytes: u64) -> Info { + let adapter = JsonlStorageAdapter::with_root(root.path().to_path_buf()); + let info = Info { + id: acp::SessionId::new("fork-bench-src"), + cwd: "/bench/workspace".to_string(), + }; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("bench runtime"); + rt.block_on(async { + adapter + .init_session(&info, acp::ModelId::new("bench-model")) + .await + .expect("init session"); + let updates_path = adapter.updates_file_path(&info).expect("updates path"); + let mut turn = 0usize; + loop { + for update in turn_updates(&info, turn) { + adapter.append_update(&info, &update).await.expect("append"); + } + turn += 1; + // Stat every 32 turns; sizes only grow. + if turn % 32 == 0 + && std::fs::metadata(&updates_path) + .map(|m| m.len()) + .unwrap_or(0) + >= target_bytes + { + break; + } + } + }); + info +} + +fn bench_fork_copy(c: &mut Criterion) { + let target_mb: u64 = std::env::var("FORK_BENCH_MB") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(16); + let root = TempDir::new().expect("tempdir"); + let source = synthesize_session(&root, target_mb * 1024 * 1024); + let adapter = JsonlStorageAdapter::with_root(root.path().to_path_buf()); + let updates_len = std::fs::metadata(adapter.updates_file_path(&source).expect("updates path")) + .expect("updates.jsonl") + .len(); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("bench runtime"); + + let mut group = c.benchmark_group("fork_copy"); + group + .sampling_mode(SamplingMode::Flat) + .sample_size(10) + .measurement_time(Duration::from_secs(30)) + .throughput(Throughput::Bytes(updates_len)); + group.bench_function( + BenchmarkId::new("copy_session_data", format!("{target_mb}MB")), + |b| { + let mut n = 0usize; + b.iter(|| { + n += 1; + let target = Info { + id: acp::SessionId::new(format!("fork-bench-dst-{n}")), + cwd: "/bench/workspace-fork".to_string(), + }; + let result = rt + .block_on(adapter.copy_session_data( + &source, + &target, + CopySessionOptions::default(), + )) + .expect("fork copy"); + // Keep each iteration's output dir from accumulating. + if let Some(dir) = adapter + .updates_file_path(&target) + .and_then(|p| p.parent().map(std::path::Path::to_path_buf)) + { + std::fs::remove_dir_all(&dir).ok(); + } + black_box(result) + }); + }, + ); + group.finish(); +} + +criterion_group!(benches, bench_fork_copy); +criterion_main!(benches); diff --git a/crates/codegen/xai-grok-shell/changelogs/0.2.112.json b/crates/codegen/xai-grok-shell/changelogs/0.2.112.json new file mode 100644 index 0000000..4cf7d38 --- /dev/null +++ b/crates/codegen/xai-grok-shell/changelogs/0.2.112.json @@ -0,0 +1,192 @@ +[ + { + "category": "features", + "description": "**New /tutorial slash command** opens an opt-in nine-topic onboarding tour of Grok.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**File attachments** now appear correctly when resuming or replaying conversations.", + "breaking_change": false + }, + { + "category": "features", + "description": "**New tool_overrides option** lets you set date cutoffs and domain allowlists for the agent's built-in search tools.", + "breaking_change": false + }, + { + "category": "features", + "description": "**New toolOverrides option** lets you set date cutoffs and domain allowlists for the agent's built-in search tools.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Terminal output** from remote clients is now recorded so read-file hints and monitors function correctly.", + "breaking_change": false + }, + { + "category": "features", + "description": "**New config options** let you add query parameters or environment-backed headers to custom model providers and control which variables reach shell tools.", + "breaking_change": false + }, + { + "category": "breaking", + "description": "**CLI version policy** now has separate soft update floors/ceilings and hard startup requirements.", + "breaking_change": true + }, + { + "category": "fixes", + "description": "**Background shell commands** now correctly report their real exit codes instead of always showing -1.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Marketplace source refreshes** no longer hang the TUI or trap you in the extensions modal.", + "breaking_change": false + }, + { + "category": "features", + "description": "**Terminal and environment fixes** are now consolidated under the `/doctor` command with clearer guidance.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Background task tray** now correctly clears killed tasks and keeps task descriptions after reconnect.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Dashboard overlay** now correctly returns after forking a dashboard-attached session.", + "breaking_change": false + }, + { + "category": "features", + "description": "**Marketplace add** now rejects non-git URLs at add time instead of failing later.", + "breaking_change": false + }, + { + "category": "features", + "description": "**Slash commands** can now show optional bracket tags (e.g. [new]) via config or remote settings.", + "breaking_change": false + }, + { + "category": "features", + "description": "**Queued prompts** now offer an [edit] mouse button alongside Send now and cancel.", + "breaking_change": false + }, + { + "category": "features", + "description": "**Voice shortcut** toggle in settings can disable the Ctrl+Space/F8 keybind without disabling voice entirely.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Linux voice dictation** now works on PipeWire versions before 1.6.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Fork** from a rewound session now copies the correct live-branch history.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Account pane** now shows name and email even after the access token expires.", + "breaking_change": false + }, + { + "category": "features", + "description": "**Image edit** can now use a remotely configured model slug instead of the hardcoded default.", + "breaking_change": false + }, + { + "category": "features", + "description": "**`grok doctor fix`** can now repair common tmux clipboard and passthrough problems.", + "breaking_change": false + }, + { + "category": "features", + "description": "**Per-provider auth helpers** now work on Windows and can run from a configurable working directory.", + "breaking_change": false + }, + { + "category": "features", + "description": "**/resume** now shows only native Grok sessions by default and shows a hint when external sessions are hidden.", + "breaking_change": false + }, + { + "category": "features", + "description": "**`grok --resume`** can now resume a session by its title as well as by ID.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Voice mode** now lets you edit already-dictated text without closing the microphone.", + "breaking_change": false + }, + { + "category": "features", + "description": "**Workflows overlay** now shows live per-agent progress and automatically follows the active phase.", + "breaking_change": false + }, + { + "category": "features", + "description": "**Workflow runs** that failed can now be resumed; scratch file limits were also increased.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Fixed startup hangs** on Linux after concurrent launches or rapid restarts.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**MCP tools** now appear without restart after enrolling or updating a managed service.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Plugin subagents** now see the same MCP tools as the parent session.", + "breaking_change": false + }, + { + "category": "features", + "description": "**Hooks** can now be defined in config.toml in addition to JSON files.", + "breaking_change": false + }, + { + "category": "features", + "description": "**Clicking** the \"still running\" status now opens the tasks pane.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Copy confirmations** now show shorter messages when the clipboard succeeds.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Repeated identical tool calls** now end the turn silently instead of showing a stop banner.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Web search** now defaults to grok-4.5.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Voice dictation** text is no longer dropped when pressing Enter to send.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Bash mode** (`!`) now shows yellow prefix and action label in minimal mode.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Parked turns** no longer spam duplicate \"Worked for\" markers in the transcript.", + "breaking_change": false + } +] diff --git a/crates/codegen/xai-grok-shell/changelogs/0.2.112.md b/crates/codegen/xai-grok-shell/changelogs/0.2.112.md new file mode 100644 index 0000000..9208bc3 --- /dev/null +++ b/crates/codegen/xai-grok-shell/changelogs/0.2.112.md @@ -0,0 +1,49 @@ +# 0.2.112 — 2026-07-24 + +## Breaking Changes + +- **CLI version policy** now has separate soft update floors/ceilings and hard startup requirements. + +## Features + +- **New /tutorial slash command** opens an opt-in nine-topic onboarding tour of Grok. +- **New tool_overrides option** lets you set date cutoffs and domain allowlists for the agent's built-in search tools. +- **New toolOverrides option** lets you set date cutoffs and domain allowlists for the agent's built-in search tools. +- **New config options** let you add query parameters or environment-backed headers to custom model providers and control which variables reach shell tools. +- **Terminal and environment fixes** are now consolidated under the `/doctor` command with clearer guidance. +- **Marketplace add** now rejects non-git URLs at add time instead of failing later. +- **Slash commands** can now show optional bracket tags (e.g. [new]) via config or remote settings. +- **Queued prompts** now offer an [edit] mouse button alongside Send now and cancel. +- **Voice shortcut** toggle in settings can disable the Ctrl+Space/F8 keybind without disabling voice entirely. +- **Image edit** can now use a remotely configured model slug instead of the hardcoded default. +- **`grok doctor fix`** can now repair common tmux clipboard and passthrough problems. +- **Per-provider auth helpers** now work on Windows and can run from a configurable working directory. +- **/resume** now shows only native Grok sessions by default and shows a hint when external sessions are hidden. +- **`grok --resume`** can now resume a session by its title as well as by ID. +- **Workflows overlay** now shows live per-agent progress and automatically follows the active phase. +- **Workflow runs** that failed can now be resumed; scratch file limits were also increased. +- **Hooks** can now be defined in config.toml in addition to JSON files. +- **Clicking** the "still running" status now opens the tasks pane. + +## Bug Fixes + +- **File attachments** now appear correctly when resuming or replaying conversations. +- **Terminal output** from remote clients is now recorded so read-file hints and monitors function correctly. +- **Background shell commands** now correctly report their real exit codes instead of always showing -1. +- **Marketplace source refreshes** no longer hang the TUI or trap you in the extensions modal. +- **Background task tray** now correctly clears killed tasks and keeps task descriptions after reconnect. +- **Dashboard overlay** now correctly returns after forking a dashboard-attached session. +- **Linux voice dictation** now works on PipeWire versions before 1.6. +- **Fork** from a rewound session now copies the correct live-branch history. +- **Account pane** now shows name and email even after the access token expires. +- **Voice mode** now lets you edit already-dictated text without closing the microphone. +- **Fixed startup hangs** on Linux after concurrent launches or rapid restarts. +- **MCP tools** now appear without restart after enrolling or updating a managed service. +- **Plugin subagents** now see the same MCP tools as the parent session. +- **Copy confirmations** now show shorter messages when the clipboard succeeds. +- **Repeated identical tool calls** now end the turn silently instead of showing a stop banner. +- **Web search** now defaults to grok-4.5. +- **Voice dictation** text is no longer dropped when pressing Enter to send. +- **Bash mode** (`!`) now shows yellow prefix and action label in minimal mode. +- **Parked turns** no longer spam duplicate "Worked for" markers in the transcript. + diff --git a/crates/codegen/xai-grok-shell/src/agent/activity.rs b/crates/codegen/xai-grok-shell/src/agent/activity.rs index 2f222e8..a74e31f 100644 --- a/crates/codegen/xai-grok-shell/src/agent/activity.rs +++ b/crates/codegen/xai-grok-shell/src/agent/activity.rs @@ -35,6 +35,17 @@ use crate::session::{SessionCommand, SessionHandle}; /// not yet exited. const FLUSH_POLL: Duration = Duration::from_millis(50); +/// Default bound on a process-exit session flush ([`AgentActivity::flush_all_sessions`]): +/// leader auto-update shutdown and the in-process agent's `/exit` / headless-quit +/// path both use it, so one wedged actor delays exit by the same amount everywhere. +/// Sessions are normally idle by then and the flush completes in milliseconds. +/// +/// Known gap: a `SessionEnd` hook configured with a longer `timeout` than this +/// is still cut off at the grace. Aligning the two needs the hook registry's +/// configured timeouts at flush time, which this layer does not see — tracked as +/// a follow-up rather than hardcoding a larger bound for every exit. +pub const SESSION_FLUSH_GRACE: Duration = Duration::from_secs(10); + /// Per-session slice of state shared with the session actor (the same `Arc`s /// the actor mutates — see the matching `SessionHandle` fields). struct SessionActivityEntry { @@ -131,9 +142,13 @@ impl AgentActivity { /// with a fresh actor gets its own signal), all against one deadline — /// `grace` bounds the **total** shutdown delay. /// - /// Call **before** cancelling the leader's root token so session state - /// is durable before the `LocalSet` drop aborts remaining tasks. Actors - /// that miss the grace are logged and abandoned. + /// Callers: the leader's auto-update / `RelaunchForUpdate` shutdown, and + /// the in-process agent worker on `/exit` / headless quit. In the leader + /// case, call **before** cancelling the root token; in the in-process case, + /// **after** the cancel that ends the worker's run loop but before its + /// `LocalSet` drops — either way, session state must be durable before the + /// drop aborts remaining tasks. Actors that miss the grace are logged and + /// abandoned. pub async fn flush_all_sessions(&self, grace: Duration) { let deadline = tokio::time::Instant::now() + grace; // Every distinct channel signaled so far (id kept for logging). @@ -148,7 +163,7 @@ impl AgentActivity { .collect(); for (id, tx) in snapshot { if !signaled.iter().any(|(_, s)| s.same_channel(&tx)) { - tracing::info!(session_id = %id, "leader shutdown: flushing session"); + tracing::info!(session_id = %id, "shutdown: flushing session"); let _ = tx.send(SessionCommand::Shutdown); signaled.push((id, tx)); } @@ -162,7 +177,7 @@ impl AgentActivity { if !tx.is_closed() { tracing::warn!( session_id = %id, - "leader shutdown: session actor did not exit within grace; proceeding" + "shutdown: session actor did not exit within grace; proceeding" ); } } diff --git a/crates/codegen/xai-grok-shell/src/agent/app.rs b/crates/codegen/xai-grok-shell/src/agent/app.rs index fb415b1..613bce4 100644 --- a/crates/codegen/xai-grok-shell/src/agent/app.rs +++ b/crates/codegen/xai-grok-shell/src/agent/app.rs @@ -60,9 +60,10 @@ pub struct LeaderAutoUpdateConfig { const AUTO_UPDATE_CHECK_TIMEOUT: Duration = Duration::from_secs(20 * 60); /// How long the auto-update shutdown waits for session actors to flush -/// before the leader exits. Sessions are idle at this point, so the flush -/// normally completes in milliseconds; the cap only bounds a wedged actor. -const AUTO_UPDATE_FLUSH_GRACE: Duration = Duration::from_secs(10); +/// before the leader exits. Aliases the shared +/// [`crate::agent::activity::SESSION_FLUSH_GRACE`] so this path and the +/// in-process agent's `/exit` / headless-quit flush cannot drift apart. +const AUTO_UPDATE_FLUSH_GRACE: Duration = crate::agent::activity::SESSION_FLUSH_GRACE; /// Consecutive busy deferrals after which an installed update proceeds /// anyway (with the graceful flush). Bounds how long a permanently-"busy" @@ -829,8 +830,8 @@ fn relay_config_for_session( } /// Start the leader's grok.com relay connection according to the start policy, -/// returning the slot where the [`RelayHandle`](crate::agent::relay::RelayHandle) -/// is parked once the connection task is running. +/// parking the [`RelayHandle`](crate::agent::relay::RelayHandle) in `slot` +/// once the connection task is running. /// /// * `relay_on_demand == false` (default — explicit `grok agent leader` /// invocation: devbox / systemd / nohup): connect **eagerly**, right now. @@ -854,29 +855,29 @@ fn relay_config_for_session( /// via `session/load`). /// /// Must be called within a `LocalSet` (uses `spawn_local`). The handle is -/// parked in a slot rather than returned from the deferred task because -/// `RelayHandle` cancels its loop on Drop; the leader shutdown path takes it -/// out of the slot to stop the relay explicitly (the `cancel` token would stop -/// it anyway). +/// parked in the caller-owned `slot` rather than returned from the deferred +/// task because `RelayHandle` cancels its loop on Drop; the leader shutdown +/// path takes it out of the slot to stop the relay explicitly (the `cancel` +/// token would stop it anyway). The slot is passed in (not created here) so +/// a deferred arm ([`DeferredRelayArm`]) parks the handle in the same slot +/// the shutdown path drains. fn spawn_leader_relay( + slot: Rc>>, relay_config: crate::agent::relay::RelayConfig, relay_on_demand: bool, mut relay_demand_rx: tokio::sync::watch::Receiver, ws_to_agent_tx: mpsc::UnboundedSender, agent_to_ws_tx: Rc>>>, cancel: tokio_util::sync::CancellationToken, -) -> Rc>> { +) { use crate::agent::relay::spawn_relay_connection; - let slot: Rc>> = - Rc::new(std::cell::RefCell::new(None)); - if !relay_on_demand { info!("Starting relay connection (eager)"); let (tx, handle) = spawn_relay_connection(relay_config, ws_to_agent_tx, cancel); *agent_to_ws_tx.lock() = Some(tx); *slot.borrow_mut() = Some(handle); - return slot; + return; } let slot_for_task = slot.clone(); @@ -903,14 +904,75 @@ fn spawn_leader_relay( *agent_to_ws_tx.lock() = Some(tx); *slot_for_task.borrow_mut() = Some(handle); }); - slot +} + +/// Everything needed to arm the leader's grok.com relay *after* startup. +/// +/// A leader that boots without auth used to disable the relay forever — the +/// decision was made once in [`run_leader`] and never revisited. On devboxes +/// that turned a transient mint-provider outage at provision time into a +/// permanently invisible box: the external auth provider succeeded minutes +/// later and the config watcher hot-reloaded the token into the leader, but +/// the relay never connected, the agent never registered, and tooling +/// reported the (healthy) box as "not found online" for its whole lifetime. +/// +/// These parts are captured in the no-auth startup path and consumed by the +/// config-update loop on the first relay-eligible +/// [`ConfigUpdate::Auth`](crate::config::reloader::ConfigUpdate::Auth). +struct DeferredRelayArm { + relay_on_demand: bool, + relay_demand_rx: tokio::sync::watch::Receiver, + ws_to_agent_tx: mpsc::UnboundedSender, + agent_to_ws_tx: Rc>>>, + cancel: tokio_util::sync::CancellationToken, + /// Shared with [`run_leader`]'s shutdown path, which drains it to stop + /// the relay explicitly. + slot: Rc>>, + grok_com_config: crate::auth::GrokComConfig, + alpha_test_key: Option, +} + +impl DeferredRelayArm { + /// Arm the relay for a hot-reloaded session if it is relay-eligible. + /// + /// Consumes the parts and returns `None` when the relay was armed. + /// Returns `Some(self)` when the session is not relay-eligible (BYOK / + /// non-x.ai issuer — see + /// [`RelayConfig::for_session`](crate::agent::relay::RelayConfig::for_session)) + /// so a later eligible token can still arm. + /// + /// Must be called within a `LocalSet` (delegates to + /// [`spawn_leader_relay`]). + fn arm_if_eligible(self, session: &GrokAuth, auth_manager: &Arc) -> Option { + let Some(relay_config) = crate::agent::relay::RelayConfig::for_session( + session, + &self.grok_com_config, + self.alpha_test_key.clone(), + Some(auth_manager.clone()), + ) else { + return Some(self); + }; + info!("Relay-eligible auth token appeared after startup — arming grok.com relay"); + spawn_leader_relay( + self.slot, + relay_config, + self.relay_on_demand, + self.relay_demand_rx, + self.ws_to_agent_tx, + self.agent_to_ws_tx, + self.cancel, + ); + None + } } /// Run the agent in leader mode, accepting IPC connections from multiple clients. /// When a grok.com session is present, the leader connects to the websocket relay -/// after startup (post-auth, post-prefetch); BYOK / no-session leaders skip it and -/// serve clients over IPC only. See [`spawn_leader_relay`] for when the relay -/// connection is opened (eager by default, demand-gated with `relay_on_demand`). +/// after startup (post-auth, post-prefetch); BYOK / no-session leaders start +/// serving clients over IPC only, then arm the relay if a relay-eligible token +/// is hot-reloaded later (see [`DeferredRelayArm`]). See [`spawn_leader_relay`] +/// for when the relay connection is opened (eager by default, demand-gated with +/// `relay_on_demand`). /// /// Startup sequence (lock-then-socket): /// 1. Acquire the leader flock FIRST — bail if another process holds it. @@ -1198,7 +1260,10 @@ pub async fn run_leader( // process so a refresh can't straddle a suspend. shared_auth_manager.start_system_power_listener(); - // Decided once here; not (re)started if a client authenticates mid-session. + // Resolved from startup auth here; when this is `None` (leader booted + // without auth) the relay is NOT permanently off — the config-update loop + // arms it later via `DeferredRelayArm` when the watcher hot-reloads a + // relay-eligible token. // The refresher lands on `shared_auth_manager` during `MvpAgent` // construction below; a relay 401 in the window before that surfaces as // a transient recovery failure and is retried, not a dead end. @@ -1363,19 +1428,42 @@ pub async fn run_leader( // connect unconditionally. Leaders auto-spawned by interactive // clients pass `relay_on_demand` and defer the WebSocket until the // first headless registration. See `spawn_leader_relay`. - let relay_handle_slot = if let Some(relay_config) = relay_config { + let relay_handle_slot: Rc< + std::cell::RefCell>, + > = Rc::new(std::cell::RefCell::new(None)); + let mut deferred_relay_arm: Option = None; + if let Some(relay_config) = relay_config { spawn_leader_relay( + relay_handle_slot.clone(), relay_config, relay_on_demand, relay_demand_rx, ws_to_agent_tx.clone(), agent_to_ws_tx.clone(), cancel_clone.clone(), - ) + ); } else { - info!("Relay disabled: no grok.com session token (BYOK / local-only leader)"); - Rc::new(std::cell::RefCell::new(None)) - }; + // No relay-eligible auth at startup (BYOK / local-only — or a + // devbox whose initial mint failed transiently). Don't decide + // "relay off" forever: park the parts so the config-update + // loop below arms the relay when the watcher hot-reloads a + // relay-eligible token. See `DeferredRelayArm`. + info!( + "Relay not started: no grok.com session token \ + (BYOK / local-only leader); will arm if an eligible \ + token is hot-reloaded" + ); + deferred_relay_arm = Some(DeferredRelayArm { + relay_on_demand, + relay_demand_rx, + ws_to_agent_tx: ws_to_agent_tx.clone(), + agent_to_ws_tx: agent_to_ws_tx.clone(), + cancel: cancel_clone.clone(), + slot: relay_handle_slot.clone(), + grok_com_config: agent_config.grok_com_config.clone(), + alpha_test_key: agent_config.endpoints.alpha_test_key.clone(), + }); + } // Spawn auto-update checker if configured. let update_cancel = cancel_clone.clone(); @@ -1502,7 +1590,24 @@ pub async fn run_leader( "expires_at": auth.expires_at.map(|e| e.to_rfc3339()), })), ); + // Cloned only while a deferred relay arm is + // pending (leader booted without auth) — `None` + // for the lifetime of a normally-authed leader. + let session_for_relay = deferred_relay_arm + .is_some() + .then(|| (*auth).clone()); auth_manager_for_config.hot_swap(*auth); + // Deferred relay arm for a leader that booted + // without auth (post-hot-swap, so the shared + // manager already holds the token when the relay + // connects). A non-eligible token (BYOK) hands + // the parts back for a later attempt. + if let (Some(arm), Some(session)) = + (deferred_relay_arm.take(), session_for_relay) + { + deferred_relay_arm = arm + .arm_if_eligible(&session, &auth_manager_for_config); + } models_manager_for_config.on_auth_changed().await; let line = internal_reload_request_line( "config-auth-reloaded", @@ -1846,7 +1951,9 @@ mod tests { let local = tokio::task::LocalSet::new(); local .run_until(async { - let slot = spawn_leader_relay( + let slot = Rc::new(std::cell::RefCell::new(None)); + spawn_leader_relay( + slot.clone(), config, false, // eager: explicit `grok agent leader` invocation demand_rx, @@ -1885,7 +1992,13 @@ mod tests { let local = tokio::task::LocalSet::new(); local .run_until(async { - let _slot = spawn_leader_relay( + // Keep an Rc on the slot for the whole test: the demand task + // drops its clone after parking the handle, and `RelayHandle` + // cancels the relay loop on Drop (mirrors `run_leader`, which + // owns the slot until shutdown). + let slot = Rc::new(std::cell::RefCell::new(None)); + spawn_leader_relay( + slot.clone(), config, true, // on-demand: spawned via spawn_leader_subprocess demand_rx, @@ -1910,6 +2023,81 @@ mod tests { cancel.cancel(); } + /// Regression test for the "leader booted without auth is invisible + /// forever" bug: a leader that starts with no session (e.g. a devbox + /// whose initial mint hit a transient provider outage) must arm the + /// relay when a relay-eligible token is later hot-reloaded — and must + /// hand the parts back (not consume them) for a non-eligible token, so + /// a later eligible one can still arm. + #[tokio::test] + async fn deferred_arm_connects_relay_when_auth_appears() { + let (addr, count) = spawn_mock_relay_server().await; + let cancel = CancellationToken::new(); + let (ws_to_agent_tx, _ws_to_agent_rx) = mpsc::unbounded_channel(); + let agent_to_ws_tx: Rc>>> = + Rc::new(Mutex::new(None)); + let (_demand_tx, demand_rx) = watch::channel(false); + let slot = Rc::new(std::cell::RefCell::new(None)); + + let grok_com_config = crate::auth::GrokComConfig { + grok_ws_url: format!("ws://{addr}"), + grok_ws_origin: format!("http://{addr}"), + ..Default::default() + }; + let tmp = tempfile::tempdir().unwrap(); + let auth_manager = Arc::new(AuthManager::new(tmp.path(), grok_com_config.clone())); + + let arm = DeferredRelayArm { + relay_on_demand: false, // bare leader: eager once armed + relay_demand_rx: demand_rx, + ws_to_agent_tx, + agent_to_ws_tx: agent_to_ws_tx.clone(), + cancel: cancel.clone(), + slot: slot.clone(), + grok_com_config, + alpha_test_key: None, + }; + + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + // A non-relay-eligible token (no x.ai issuer) must not arm + // and must hand the parts back. + let ineligible = GrokAuth::test_default(); + let arm = arm + .arm_if_eligible(&ineligible, &auth_manager) + .expect("non-eligible token must hand the parts back"); + assert!(slot.borrow().is_none(), "no handle parked yet"); + assert_eq!( + count.load(Ordering::SeqCst), + 0, + "non-eligible token must not connect the relay" + ); + + // A relay-eligible x.ai OIDC token arms the relay eagerly. + let eligible = GrokAuth { + auth_mode: AuthMode::Oidc, + oidc_issuer: Some(crate::auth::XAI_OAUTH2_ISSUER.to_string()), + ..GrokAuth::test_default() + }; + assert!( + arm.arm_if_eligible(&eligible, &auth_manager).is_none(), + "eligible token must consume the arm parts" + ); + assert!( + slot.borrow().is_some(), + "handle must be parked in the shared shutdown slot" + ); + assert!( + agent_to_ws_tx.lock().is_some(), + "outbound relay sender must be installed" + ); + wait_for_connection(&count, "deferred arm after auth hot-reload").await; + }) + .await; + cancel.cancel(); + } + /// The watcher-injected internal reload requests must carry the ACP /// wire-level `_` extension prefix. `agent-client-protocol`'s inbound /// decoder routes non-built-in methods to `ext_method` only when diff --git a/crates/codegen/xai-grok-shell/src/agent/config.rs b/crates/codegen/xai-grok-shell/src/agent/config.rs index ed20d9f..35f0cc7 100644 --- a/crates/codegen/xai-grok-shell/src/agent/config.rs +++ b/crates/codegen/xai-grok-shell/src/agent/config.rs @@ -2043,9 +2043,18 @@ impl Config { t.remove("auth_provider"); t.remove("model_providers"); } + let parsed_mcp_servers = + crate::util::config::parse_mcp_servers_from_toml(&raw_without_model_sections); + if let toml::Value::Table(ref mut t) = raw_without_model_sections { + t.remove("mcp_servers"); + } crate::config::deep_merge_toml(&mut base, &raw_without_model_sections); + if let toml::Value::Table(ref mut t) = base { + t.remove("mcp_servers"); + } let (mut config, user_unused) = Self::deserialize_collecting_unrecognized(base, &raw_without_model_sections)?; + config.mcp_servers = parsed_mcp_servers.into_iter().collect(); if !user_unused.is_empty() { let keys = user_unused.join(", "); tracing::warn!( @@ -3445,6 +3454,15 @@ pub fn apply_external_otel_remote_policy(settings: Option<&crate::util::config:: } /// Seed free-function remote caches after writing `Config.remote_settings`. pub fn apply_remote_settings_side_effects(settings: Option<&crate::util::config::RemoteSettings>) { + if let Some(s) = settings { + let origin_trusted = crate::util::is_prod_cli_chat_proxy_url( + &EndpointsConfig::from_effective_config().proxy_url(), + ); + xai_grok_config::signed_policy::apply_remote_managed_config_signature_verification( + s.managed_config_signature_verification, + origin_trusted, + ); + } crate::util::config::cache_remote_mcp_startup_timeout_secs( settings.and_then(|s| s.mcp_startup_timeout_secs), ); @@ -5954,6 +5972,45 @@ reasoning_effort = "low" .expect("warm cache resolves"); assert_eq!(resolved.api_key.as_deref(), Some("ws-token")); } + /// GBT-4128: bad `[mcp_servers.*]` entries are dropped, not fatal. + #[test] + fn invalid_mcp_server_stub_does_not_fail_config_load() { + let raw_config: toml::Value = toml::from_str( + r#" + [mcp_servers.github] + enabled = false + + mcp_servers.broken = "not-a-table" + + [mcp_servers.also_broken] + enabled = "yes" + + [mcp_servers.linear] + command = "npx" + args = ["-y", "mcp-remote", "https://mcp.linear.app/mcp"] + "#, + ) + .unwrap(); + let cfg = Config::new_from_toml_cfg(&raw_config) + .expect("bad mcp stubs must be dropped, not fail whole config"); + assert!( + !cfg.mcp_servers.contains_key("broken"), + "non-table entry is dropped" + ); + assert!( + !cfg.mcp_servers.contains_key("also_broken"), + "wrong-type enabled is dropped" + ); + assert!( + !cfg.mcp_servers.contains_key("github"), + "transport-less stub is dropped (disable via disabled_mcp_servers)" + ); + assert!( + cfg.mcp_servers.contains_key("linear"), + "valid MCP neighbor must still load" + ); + assert!(cfg.mcp_servers["linear"].enabled); + } /// The lenient parser warns per problem and never fails the whole /// config. #[test] @@ -12522,4 +12579,81 @@ default = "grok-4.5" assert!(!r.value); assert_eq!(r.source, ConfigSource::Remote); } + #[test] + #[serial_test::serial(remote_sig_disarm)] + fn remote_settings_disarm_managed_config_signatures() { + xai_grok_config::signed_policy::apply_remote_managed_config_signature_verification( + Some(true), + true, + ); + assert!(xai_grok_config::signed_policy::verification_active()); + let settings = crate::util::config::RemoteSettings { + managed_config_signature_verification: Some(false), + ..Default::default() + }; + apply_remote_settings_side_effects(Some(&settings)); + assert!(!xai_grok_config::signed_policy::verification_active()); + let settings = crate::util::config::RemoteSettings { + managed_config_signature_verification: Some(true), + ..Default::default() + }; + apply_remote_settings_side_effects(Some(&settings)); + assert!(xai_grok_config::signed_policy::verification_active()); + xai_grok_config::signed_policy::apply_remote_managed_config_signature_verification( + Some(false), + true, + ); + apply_remote_settings_side_effects(None); + assert!(!xai_grok_config::signed_policy::verification_active()); + xai_grok_config::signed_policy::apply_remote_managed_config_signature_verification( + Some(true), + true, + ); + assert!(xai_grok_config::signed_policy::verification_active()); + } + /// Keyed path: prod proxy origin can disarm; env override cannot. + #[test] + #[serial_test::serial(remote_sig_disarm)] + fn remote_settings_disarm_requires_prod_proxy_when_keys_embedded() { + xai_grok_config::signed_policy::apply_remote_managed_config_signature_verification( + Some(true), + true, + ); + assert!(xai_grok_config::signed_policy::verification_active()); + let settings = crate::util::config::RemoteSettings { + managed_config_signature_verification: Some(false), + ..Default::default() + }; + unsafe { + std::env::remove_var("GROK_CLI_CHAT_PROXY_BASE_URL"); + } + apply_remote_settings_side_effects(Some(&settings)); + assert!( + !xai_grok_config::signed_policy::verification_active(), + "prod proxy origin must allow disarm when keys are embedded" + ); + xai_grok_config::signed_policy::apply_remote_managed_config_signature_verification( + Some(true), + true, + ); + assert!(xai_grok_config::signed_policy::verification_active()); + unsafe { + std::env::set_var( + "GROK_CLI_CHAT_PROXY_BASE_URL", + "https://attacker.example/v1", + ); + } + apply_remote_settings_side_effects(Some(&settings)); + assert!( + xai_grok_config::signed_policy::verification_active(), + "env-overridden proxy must not be able to disarm keyed verification" + ); + unsafe { + std::env::remove_var("GROK_CLI_CHAT_PROXY_BASE_URL"); + } + xai_grok_config::signed_policy::apply_remote_managed_config_signature_verification( + Some(true), + true, + ); + } } diff --git a/crates/codegen/xai-grok-shell/src/agent/init.rs b/crates/codegen/xai-grok-shell/src/agent/init.rs index 446cf77..0eca53f 100644 --- a/crates/codegen/xai-grok-shell/src/agent/init.rs +++ b/crates/codegen/xai-grok-shell/src/agent/init.rs @@ -23,9 +23,12 @@ pub fn bootstrap( auth_manager: &Arc, prefetched: Option>, ) -> Result<(AgentConfig, ModelsManager), String> { - // Fail closed before any policy is read: a tampered managed policy must not run unmanaged. + // Remote kill-switch before the gate (settings-only prefetch — no managed-config + // sync, so a live server cannot heal a tampered policy before fail-closed). + let mut cfg = cfg.clone(); + ensure_remote_settings_side_effects(&mut cfg, false); crate::managed_config::managed_policy_gate()?; - let cfg = resolve_config(cfg, auth_manager); + let cfg = resolve_config(&cfg, auth_manager); cfg.validate_model_filters()?; init_process(&cfg, auth_manager); let models_manager = ModelsManager::from_config(&cfg, prefetched, auth_manager.clone())?; @@ -48,6 +51,42 @@ pub(crate) fn exit_on_config_error(e: String) -> T { std::process::exit(1); } +/// Fill `remote_settings` if absent and apply process-global remote side effects +/// (signature kill-switch and caches). Safe to call more than once. +/// +/// `sync_managed`: when true, missing-settings fallback may also refresh +/// managed-config. Must be false before the managed-policy gate. +fn ensure_remote_settings_side_effects(cfg: &mut AgentConfig, sync_managed: bool) { + // Fallback: if the client didn't pre-supply remote settings, fetch them + // now so remote-settings-gated features work regardless of which client + // spawned us. Clients that already call `start_early_prefetch()` and + // thread the result into `cfg.remote_settings` skip this entirely. + if cfg.remote_settings.is_none() { + let handle = if sync_managed { + crate::agent::models::start_early_prefetch(Some(cfg.grok_com_config.clone())) + } else { + crate::agent::models::start_early_prefetch_settings_only(Some( + cfg.grok_com_config.clone(), + )) + }; + if let Some(handle) = handle { + match handle.join() { + Ok(result) => { + cfg.remote_settings = result.settings; + crate::util::config::set_remote_campaigns_from_settings( + cfg.remote_settings.as_ref(), + ); + tracing::info!("remote_settings fetched as shell-level fallback"); + } + Err(_) => { + tracing::warn!("remote_settings fallback prefetch thread panicked"); + } + } + } + } + crate::agent::config::apply_remote_settings_side_effects(cfg.remote_settings.as_ref()); +} + /// Config transform: apply managed settings, fetch remote settings, /// resolve storage mode. fn resolve_config(cfg: &AgentConfig, auth_manager: &AuthManager) -> AgentConfig { @@ -74,29 +113,10 @@ fn resolve_config(cfg: &AgentConfig, auth_manager: &AuthManager) -> AgentConfig tracing::info!(field = %e.path, value = %e.value, source = %e.source, "policy override"); } - // Fallback: if the client didn't pre-supply remote settings, fetch them - // now so remote-settings-gated features work regardless of which client - // spawned us. Clients that already call `start_early_prefetch()` and - // thread the result into `cfg.remote_settings` skip this entirely. - if cfg.remote_settings.is_none() - && let Some(handle) = - crate::agent::models::start_early_prefetch(Some(cfg.grok_com_config.clone())) - { - match handle.join() { - Ok(result) => { - cfg.remote_settings = result.settings; - crate::util::config::set_remote_campaigns_from_settings( - cfg.remote_settings.as_ref(), - ); - tracing::info!("remote_settings fetched as shell-level fallback"); - } - Err(_) => { - tracing::warn!("remote_settings fallback prefetch thread panicked"); - } - } - } + // Idempotent: bootstrap may already have fetched + applied side effects for the gate. + // Full prefetch (with managed-config sync when stale) is allowed after the gate. + ensure_remote_settings_side_effects(&mut cfg, true); crate::util::config::sync_campaign_fields(&mut cfg); - crate::agent::config::apply_remote_settings_side_effects(cfg.remote_settings.as_ref()); // env var > remote settings > Local. Skip remote settings for Generic (grok -p, subagents). if cfg.storage_mode == StorageMode::Local @@ -128,6 +148,12 @@ fn init_process(cfg: &AgentConfig, auth_manager: &AuthManager) { use std::sync::Once; static INIT: Once = Once::new(); INIT.call_once(|| { + // Every agent mode (stdio/headless/leader and the in-process TUI + // agent) passes through here, so diagnostic uploads always carry + // the version stamp and the resource ceilings in effect. + xai_grok_telemetry::unified_log::set_version(xai_grok_version::VERSION); + crate::util::limits::log_effective_limits(); + if !cfg!(test) { // Clear a logged-out team's files before the background sync runs. crate::managed_config::clear_orphan(); diff --git a/crates/codegen/xai-grok-shell/src/agent/models.rs b/crates/codegen/xai-grok-shell/src/agent/models.rs index 01e262a..d3413a2 100644 --- a/crates/codegen/xai-grok-shell/src/agent/models.rs +++ b/crates/codegen/xai-grok-shell/src/agent/models.rs @@ -1570,19 +1570,31 @@ fn resolve_prefetch_env(grok_com_config: Option) -> Option) -> Option { let env = resolve_prefetch_env_with_auth(auth)?; - Some(spawn_prefetch_thread(env)) + Some(spawn_prefetch_thread(env, true)) } /// Start model + settings prefetch on a background thread. /// /// Convenience wrapper that reads cached auth from disk. Prefer /// `start_early_prefetch_with_auth` when you have pre-resolved credentials. +/// Also runs a best-effort managed-config sync when the cache is stale. pub fn start_early_prefetch(grok_com_config: Option) -> Option { let env = resolve_prefetch_env(grok_com_config)?; - Some(spawn_prefetch_thread(env)) + Some(spawn_prefetch_thread(env, true)) } -fn spawn_prefetch_thread(env: PrefetchEnv) -> EarlyPrefetchHandle { +/// Prefetch models + remote settings only — **no** managed-config sync. +/// +/// Used before the managed-policy gate so a kill-switch can apply on cold start +/// without healing a tampered on-disk policy before the fail-closed gate runs. +pub fn start_early_prefetch_settings_only( + grok_com_config: Option, +) -> Option { + let env = resolve_prefetch_env(grok_com_config)?; + Some(spawn_prefetch_thread(env, false)) +} + +fn spawn_prefetch_thread(env: PrefetchEnv, sync_managed: bool) -> EarlyPrefetchHandle { std::thread::spawn(move || { let mut timer = crate::instrumentation_timer!("startup.early_prefetch"); let proxy_endpoint = env.endpoints.proxy_url(); @@ -1592,7 +1604,9 @@ fn spawn_prefetch_thread(env: PrefetchEnv) -> EarlyPrefetchHandle { env.auth.as_ref(), env.model_fetch_auth, ); - if (env.endpoints.deployment_key.is_some() || crate::managed_config::has_active_team_auth()) + if sync_managed + && (env.endpoints.deployment_key.is_some() + || crate::managed_config::has_active_team_auth()) && crate::config::is_managed_config_stale_for( &crate::managed_config::current_serving_identity(), ) diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/agent_ops.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/agent_ops.rs index 4abd105..976393e 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/agent_ops.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/agent_ops.rs @@ -489,6 +489,16 @@ impl MvpAgent { pub fn set_activity(&mut self, activity: crate::agent::activity::AgentActivity) { self.activity = activity; } + /// Send [`SessionCommand::Shutdown`] to every live session actor and wait + /// up to `grace` for them to exit (SessionEnd hooks, memory save, etc.). + /// + /// Call on non-leader process quit **after** the cancel token fires but + /// **before** dropping the agent / exiting the process, so session actors + /// are not killed mid-hook. Mirrors the leader auto-update / relaunch + /// flush path ([`crate::agent::activity::AgentActivity::flush_all_sessions`]). + pub async fn flush_all_sessions(&self, grace: std::time::Duration) { + self.activity.flush_all_sessions(grace).await; + } /// Install the channel that fans new session cwds into the leader's /// `ConfigFileWatcher::watch_path`. Called once after /// the watcher is constructed in `agent/app.rs`. In simple / @@ -3610,7 +3620,11 @@ impl MvpAgent { let hooks_val = hooks_config.as_value(); let (specs, errors) = xai_grok_hooks::config::parse_hooks_from_value_with_dir( &hooks_val, - &format!("agent:{}", agent_definition.name), + &format!( + "{}{}", + xai_grok_hooks::config::AGENT_HOOK_PREFIX, + agent_definition.name + ), std::path::Path::new(&session_info.cwd), ); for e in &errors { diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/handle_request.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/handle_request.rs index 9ccd604..2feda57 100644 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/handle_request.rs +++ b/crates/codegen/xai-grok-shell/src/agent/subagent/handle_request.rs @@ -835,7 +835,11 @@ pub(crate) async fn run_shell_child( let hooks_val = hooks_config.as_value(); let (specs, errors) = xai_grok_hooks::config::parse_hooks_from_value_with_dir( &hooks_val, - &format!("agent:{}", definition.name), + &format!( + "{}{}", + xai_grok_hooks::config::AGENT_HOOK_PREFIX, + definition.name + ), &ctx.parent_cwd, ); for e in &errors { diff --git a/crates/codegen/xai-grok-shell/src/extensions/hooks.rs b/crates/codegen/xai-grok-shell/src/extensions/hooks.rs index 569cac9..449923f 100644 --- a/crates/codegen/xai-grok-shell/src/extensions/hooks.rs +++ b/crates/codegen/xai-grok-shell/src/extensions/hooks.rs @@ -303,6 +303,7 @@ mod tests { timeout_ms: 5000, source_dir: PathBuf::from("/tmp"), extra_env: HashMap::new(), + layer: xai_grok_hooks::config::HookProvenance::File, } } diff --git a/crates/codegen/xai-grok-shell/src/inspect/mod.rs b/crates/codegen/xai-grok-shell/src/inspect/mod.rs index e1595f1..11f3157 100644 --- a/crates/codegen/xai-grok-shell/src/inspect/mod.rs +++ b/crates/codegen/xai-grok-shell/src/inspect/mod.rs @@ -75,6 +75,9 @@ pub struct InspectReport { pub external_compat: ExternalCompatReport, #[serde(skip_serializing_if = "Vec::is_empty")] pub config_warnings: Vec, + /// Invalid or ignored `[mcp_servers.*]` entries. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub mcp_config_problems: Vec, } #[derive(Debug, Serialize)] @@ -382,6 +385,7 @@ async fn build_report(cwd: &Path) -> InspectReport { .as_ref() .map(|c| c.config_warnings.clone()) .unwrap_or_default(); + let mcp_config_problems = crate::util::config::load_mcp_server_problems_with_project(cwd); InspectReport { grok_version: xai_grok_version::VERSION.to_string(), @@ -404,6 +408,7 @@ async fn build_report(cwd: &Path) -> InspectReport { config_sources: configs, external_compat, config_warnings, + mcp_config_problems, } } @@ -664,36 +669,42 @@ fn list_hooks( discovered_plugins: &[xai_grok_agent::plugins::DiscoveredPlugin], ) -> Vec { let all_on = xai_grok_tools::types::compat::CompatConfig::default(); - let source_paths = crate::util::hooks::discover_hook_source_paths(git_root, &all_on); - let (global_sources, project_sources) = source_paths.as_sources(project_trusted); - + // Route through the same assembly as session startup so config-layer hooks + // (config.toml / managed_config.toml / requirements.toml) appear in `/hooks` + // status alongside file hooks, each carrying its provenance name prefix. + let config_layers = xai_grok_config::hook_config_layers(); let (registry, _errors) = - xai_grok_hooks::discovery::load_hooks_from_sources(&global_sources, &project_sources); - - let home_dir = dirs::home_dir(); - let grok_home = xai_grok_config::grok_home(); + crate::util::hooks::assemble_hooks(&config_layers, git_root, &all_on, project_trusted); let mut entries: Vec = registry .all_hooks() .into_iter() .map(|h| { - let is_user_scope = h.source_dir.starts_with(&grok_home) - || home_dir.as_deref().is_some_and(|home| { - h.source_dir.starts_with(home.join(".cursor")) - || h.source_dir.starts_with(home.join(".claude")) - }); - let source = if is_user_scope { - ConfigSource::User { - path: h.source_dir.clone(), - } - } else { - ConfigSource::Project { - path: h.source_dir.clone(), - } + // Classify via the shared `hook_origin` (typed provenance + file-tier + // name prefix), the same classifier telemetry uses, so admin/system + // hooks aren't mislabeled and the two surfaces can't diverge. + use xai_grok_hooks::config::HookOrigin as O; + // Config-layer hooks store the layer's directory in `source_dir`; + // rejoin the tier's filename so inspect shows the actual config file. + let config_file = |name: &str| h.source_dir.join(name); + let path = h.source_dir.clone(); + let source = match xai_grok_hooks::config::hook_origin(h) { + O::SystemManaged | O::Managed => ConfigSource::Managed { + path: Some(config_file(xai_grok_config::MANAGED_CONFIG_FILENAME)), + }, + O::Requirements => ConfigSource::Managed { + path: Some(config_file(xai_grok_config::REQUIREMENTS_FILENAME)), + }, + O::UserConfig => ConfigSource::ConfigToml { + path: config_file(xai_grok_config::USER_CONFIG_FILENAME), + }, + O::ProjectFile => ConfigSource::Project { path }, + // File/plugin/agent/unknown hooks are user-scoped for display. + O::UserFile | O::Plugin | O::Agent | O::Unknown => ConfigSource::User { path }, }; let vendor = derive_vendor(&h.source_dir.display().to_string()).map(String::from); HookEntry { - event: format!("{:?}", h.event), + event: h.event.to_string(), hook_type: h.handler_type.as_str().to_string(), target: h .command @@ -1243,6 +1254,25 @@ fn render_config_warnings( out } +fn render_mcp_config_problems(problems: &[crate::util::config::McpServerConfigProblem]) -> String { + use crate::util::config::McpServerProblemSeverity; + use std::fmt::Write as _; + + if problems.is_empty() { + return String::new(); + } + let mut out = String::from("\n MCP Config Problems\n"); + let _ = writeln!(out, " {TREE} {} problem(s)", problems.len()); + for p in problems { + let severity = match p.severity { + McpServerProblemSeverity::Error => "error", + McpServerProblemSeverity::Warning => "warning", + }; + let _ = writeln!(out, " {TREE} [{severity}] {}", p.message); + } + out +} + fn render_harness_compatibility(report: &ExternalCompatReport) -> String { use std::fmt::Write as _; @@ -1514,6 +1544,7 @@ fn print_human(r: &InspectReport) { } print!("{}", render_config_warnings(&r.config_warnings)); + print!("{}", render_mcp_config_problems(&r.mcp_config_problems)); print!("{}", render_harness_compatibility(&r.external_compat)); } diff --git a/crates/codegen/xai-grok-shell/src/remote/client.rs b/crates/codegen/xai-grok-shell/src/remote/client.rs index 9b4f4ee..dff7a7d 100644 --- a/crates/codegen/xai-grok-shell/src/remote/client.rs +++ b/crates/codegen/xai-grok-shell/src/remote/client.rs @@ -294,7 +294,10 @@ impl BackendClient { .connect_timeout(Duration::from_secs(10)) .timeout(DEFAULT_TIMEOUT) .build() - .expect("failed to build HTTP client") + .unwrap_or_else(|e| { + tracing::warn!(error = %e, "failed to build backend HTTP client; falling back to shared client"); + crate::http::shared_client() + }) } pub fn new() -> Self { let reqwest_client = Self::build_default_client(); @@ -435,7 +438,7 @@ impl BackendClient { ) -> Result { let headers = self.auth_header_map().await?; let builder = xai_file_utils::trace_context::inject_trace_context_into_request( - builder.headers(headers), + builder.timeout(DEFAULT_TIMEOUT).headers(headers), ); let request = builder.build()?; self.client.execute(request).await.map_err(|e| match e { diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/hooks_plugins.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/hooks_plugins.rs index d3ef9ae..62a0522 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/hooks_plugins.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/hooks_plugins.rs @@ -887,8 +887,18 @@ impl SessionActor { hook_reg.remove_by_prefix("plugin/"); hook_reg.append_specs(new_specs); } else if !new_specs.is_empty() { - let (mut new_reg, _) = - xai_grok_hooks::discovery::load_hooks_from_sources(&[], &[]); + // No registry yet: bootstrap config-layer and file hooks (as + // reload_hooks_impl does), not empty sources, so a plugin-first + // snapshot doesn't drop config hooks. + let git_root = + xai_grok_workspace::session::git::find_git_root_from_path(session_cwd).ok(); + let is_trusted = + crate::agent::folder_trust::resolve_and_record(session_cwd, None, false); + let (mut new_reg, _errs) = crate::util::hooks::discover_hooks( + git_root.as_deref(), + &self.rebuild_spec.compat, + is_trusted, + ); new_reg.append_specs(new_specs); *reg = Some(Arc::new(new_reg)); } diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs index ca4845c..29b1b8e 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs @@ -30,6 +30,111 @@ fn drop_cli_catchall_allows( } (kept, dropped) } +/// Build the per-session current-thread tokio runtime. +/// +/// Construction acquires fds (epoll/kqueue, waker) and fails with +/// `EMFILE`/`EAGAIN` under resource pressure. Extracted so the containment +/// contract — exhaustion returns `Err`, never aborts — is testable +/// (`runtime_containment_tests`). +pub(crate) fn build_session_runtime() -> std::io::Result { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() +} +/// Building the session runtime under fd exhaustion must return `Err`, never +/// panic (under `panic=abort` a panic kills every live session). +/// +/// The rlimit is lowered only in a re-exec'd child (the `xai-gix-status` +/// pattern), so parallel tests are unaffected; stdout markers distinguish +/// skip (unenforceable environment) from pass/fail. +#[cfg(all(test, unix))] +mod runtime_containment_tests { + use super::build_session_runtime; + /// Env marker dispatching the re-exec'd test binary into child logic. + const CHILD_ENV: &str = "XAI_GROK_SHELL_RUNTIME_CONTAINMENT_CHILD"; + const PASS_MARK: &str = "runtime-build-contained:"; + const SKIP_MARK: &str = "skip-child:"; + /// Child: lower RLIMIT_NOFILE, fill the fd table, assert `Err`. + fn run_child() -> ! { + let mut lim = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut lim) } != 0 { + println!("{SKIP_MARK} getrlimit failed"); + std::process::exit(0); + } + lim.rlim_cur = 64.min(lim.rlim_max); + if unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &lim) } != 0 { + println!("{SKIP_MARK} setrlimit failed"); + std::process::exit(0); + } + let mut held = Vec::new(); + loop { + let fd = unsafe { libc::dup(0) }; + if fd < 0 { + break; + } + held.push(fd); + if held.len() > 4096 { + println!("{SKIP_MARK} fd limit not enforced"); + std::process::exit(0); + } + } + match build_session_runtime() { + Err(e) => { + println!("{PASS_MARK} {e}"); + std::process::exit(0); + } + Ok(_) => { + println!("{SKIP_MARK} runtime built despite full fd table"); + std::process::exit(0); + } + } + } + /// Doubles as the child entry point when `CHILD_ENV` is set. + #[test] + fn child_entry_runtime_build_under_fd_exhaustion() { + if std::env::var_os(CHILD_ENV).is_some() { + run_child(); + } + } + #[test] + fn runtime_build_failure_is_contained() { + let filter = module_path!() + .split_once("::") + .map(|(_, rest)| rest) + .unwrap_or_default(); + let exe = std::env::current_exe().expect("current_exe"); + let mut cmd = std::process::Command::new(exe); + cmd.arg("--exact") + .arg(format!( + "{filter}::child_entry_runtime_build_under_fd_exhaustion" + )) + .arg("--nocapture") + .arg("--test-threads=1") + .env(CHILD_ENV, "1") + .stdin(std::process::Stdio::null()); + xai_tty_utils::detach_std_command(&mut cmd); + let out = cmd.output().expect("spawn child test process"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success() && !stderr.contains("panicked at"), + "child aborted/panicked instead of containing the failure \ + (status: {:?})\nstdout:\n{stdout}\nstderr:\n{stderr}", + out.status + ); + if stdout.contains(SKIP_MARK) { + eprintln!("skipped: {stdout}"); + return; + } + assert!( + stdout.contains(PASS_MARK), + "no pass/skip marker (filter matched nothing?)\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + } +} #[cfg(test)] mod cli_catchall_drop_tests { use super::drop_cli_catchall_allows; @@ -2184,10 +2289,17 @@ pub(crate) async fn spawn_session_on_thread( }; (initial_last_compaction, initial_prompt_texts) }; - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("session runtime"); + let rt = match build_session_runtime() { + Ok(rt) => rt, + Err(e) => { + tracing::error!( + error = %e, + "failed to build session runtime (resource exhaustion?)" + ); + let _ = init_tx.send(Err(xai_grok_agent::AgentBuildError::RuntimeBuild(e))); + return; + } + }; let local = tokio::task::LocalSet::new(); local.block_on(&rt, async move { let _trace_span = parent_traceparent.as_ref().map(|tp| { @@ -2320,15 +2432,28 @@ pub(crate) async fn spawn_session_on_thread( })); let _ = session_done_rx.await; }); - }) - .expect("spawn session thread"); + }); + let join_handle = match join_handle { + Ok(h) => h, + Err(e) => { + tracing::error!( + error = %e, + "failed to spawn session thread (thread/PID limit or memory pressure?)" + ); + return Err( + acp::Error::internal_error().data(format!("failed to spawn session thread: {e}")) + ); + } + }; let init = init_rx .await .map_err(|_| { tracing::error!("Session thread panicked during initialization"); acp::Error::internal_error().data("session thread panicked during initialization") })? - .map_err(|e| acp::Error::internal_error().data(format!("agent building failed: {e}")))?; + .map_err(|e| { + acp::Error::internal_error().data(format!("session initialization failed: {e}")) + })?; Ok(( init.handle, init.permission_events_rx, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/client_hooks_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/client_hooks_tests.rs index 8c755bc..4002c41 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/client_hooks_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/client_hooks_tests.rs @@ -711,6 +711,7 @@ fn file_registry_with_stop_spec( timeout_ms: 5000, source_dir: std::path::PathBuf::from("/tmp"), extra_env: std::collections::HashMap::new(), + layer: xai_grok_hooks::config::HookProvenance::File, }]); registry } diff --git a/crates/codegen/xai-grok-shell/src/session/merge.rs b/crates/codegen/xai-grok-shell/src/session/merge.rs index e59b65e..40cc3ac 100644 --- a/crates/codegen/xai-grok-shell/src/session/merge.rs +++ b/crates/codegen/xai-grok-shell/src/session/merge.rs @@ -275,7 +275,7 @@ pub fn merge( hostname: r.hostname, source: source.to_string(), model_id: r.model_id, - num_messages: r.last_turn_number.max(0) as usize, + num_messages: local.num_messages.max(r.last_turn_number.max(0) as usize), last_active_at: merged_last_active, branch: local.branch, repo_name: local.repo_name, @@ -446,6 +446,37 @@ mod tests { assert_eq!(merged[0].source, "both"); } + #[test] + fn stale_remote_turn_counter_does_not_demote_local_sessions_to_empty() { + // The registry's last_turn_number is updated fire-and-forget and can + // stay at 0 for sessions with real local turns. The merged row must + // keep the local num_messages, or dedup_empty_sessions collapses every + // such same-cwd session into a single "empty draft" row — hiding real + // sessions (and their unread indicators) from every list surface. + let local = vec![ + make_summary("s1", "first real session", "2026-03-01T00:00:00Z"), + make_summary("s2", "second real session", "2026-03-01T01:00:00Z"), + ]; + let remote = vec![ + SessionRecord { + last_turn_number: 0, + ..make_remote("s1", "first real session", "2026-03-01T00:00:00Z") + }, + SessionRecord { + last_turn_number: 0, + ..make_remote("s2", "second real session", "2026-03-01T01:00:00Z") + }, + ]; + let merged = merge(remote, local, None, &[], 20); + assert_eq!(merged.len(), 2, "both real sessions must survive the merge"); + for row in &merged { + assert_eq!( + row.num_messages, 10, + "local num_messages wins over a stale 0" + ); + } + } + #[test] fn remote_overwrite_preserves_local_metadata() { let local = vec![Summary { diff --git a/crates/codegen/xai-grok-shell/src/session/telemetry.rs b/crates/codegen/xai-grok-shell/src/session/telemetry.rs index cdaccf1..2ff5c20 100644 --- a/crates/codegen/xai-grok-shell/src/session/telemetry.rs +++ b/crates/codegen/xai-grok-shell/src/session/telemetry.rs @@ -88,22 +88,19 @@ pub(crate) fn format_hook_name(spec: &xai_grok_hooks::config::HookSpec) -> Strin } } -/// Provenance from the namespace prefix each loader stamps on the spec name: -/// `global/` → user, `project/` → project, `plugin/` → plugin, `agent:` → -/// agent, else unknown. (Source-dir classification was wrong — both global and -/// project dirs contain `/.grok/`.) +/// Provenance for telemetry, mapped from the shared [`hook_origin`] classifier so +/// this and `/hooks` inspect can't diverge. fn format_hook_source(spec: &xai_grok_hooks::config::HookSpec) -> &'static str { - let name = spec.name.as_str(); - if name.starts_with("global/") { - "userSettings" - } else if name.starts_with("project/") { - "projectSettings" - } else if name.starts_with("plugin/") { - "pluginHook" - } else if name.starts_with("agent:") { - "agentHook" - } else { - "unknown" + use xai_grok_hooks::config::HookOrigin as O; + match xai_grok_hooks::config::hook_origin(spec) { + O::SystemManaged | O::Managed => "managedConfig", + O::Requirements => "requirementsConfig", + O::UserConfig => "userConfig", + O::UserFile => "userSettings", + O::ProjectFile => "projectSettings", + O::Plugin => "pluginHook", + O::Agent => "agentHook", + O::Unknown => "unknown", } } diff --git a/crates/codegen/xai-grok-shell/src/terminal/adapter.rs b/crates/codegen/xai-grok-shell/src/terminal/adapter.rs index eb8dbbb..67ca57f 100644 --- a/crates/codegen/xai-grok-shell/src/terminal/adapter.rs +++ b/crates/codegen/xai-grok-shell/src/terminal/adapter.rs @@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use super::exit_watcher::{poll_for_terminal_exit, release_terminal, watch_for_exit}; -use super::output_recorder::OutputRecorder; +use super::output_recorder::{OutputRecorder, read_log_tail}; use agent_client_protocol as acp; use xai_acp_lib::AcpAgentGatewaySender as GatewaySender; use xai_grok_tools::computer::types::{ @@ -42,6 +42,7 @@ pub(super) struct TrackedTask { kind: TaskKind, owner_session_id: Option, description: Option, + output_byte_limit: usize, } /// Hand-written (`SystemTime` has no `Default`); call sites spread from it. @@ -64,6 +65,7 @@ impl Default for TrackedTask { kind: TaskKind::Bash, owner_session_id: None, description: None, + output_byte_limit: crate::terminal::DEFAULT_OUTPUT_BYTE_LIMIT, } } } @@ -79,7 +81,7 @@ impl TrackedTask { } pub(super) fn to_snapshot(&self, task_id: &str, out: SnapshotOutput) -> TaskSnapshot { - let completed = self.completed || out.exit_code.is_some(); + let completed = self.completed || out.exit_code.is_some() || out.signal.is_some(); TaskSnapshot { task_id: task_id.to_string(), command: self.command.clone(), @@ -272,6 +274,7 @@ impl TerminalBackend for AcpTerminalAdapter { kind: request.kind, owner_session_id: request.owner_session_id.clone(), description, + output_byte_limit: request.output_byte_limit, ..Default::default() }, ); @@ -309,7 +312,11 @@ impl TerminalBackend for AcpTerminalAdapter { // under the lock and read the log file after releasing it. enum Resolved { Ready(TaskSnapshot), - FromLog(TaskSnapshot, PathBuf), + FromLog { + snapshot: TaskSnapshot, + output_file: PathBuf, + limit: usize, + }, Missing, } let resolved = { @@ -339,8 +346,10 @@ impl TerminalBackend for AcpTerminalAdapter { }, )) } - (None, Some(tracked)) => Resolved::FromLog( - tracked.to_snapshot( + // Live poll failed: a completed task keeps its authoritative + // last_output; only a still-running task falls back to the log. + (None, Some(tracked)) => { + let snapshot = tracked.to_snapshot( task_id, SnapshotOutput { output: tracked.last_output.clone(), @@ -348,9 +357,17 @@ impl TerminalBackend for AcpTerminalAdapter { exit_code: tracked.exit_code, signal: tracked.signal.clone(), }, - ), - tracked.output_file.clone(), - ), + ); + if snapshot.completed { + Resolved::Ready(snapshot) + } else { + Resolved::FromLog { + snapshot, + output_file: tracked.output_file.clone(), + limit: tracked.output_byte_limit, + } + } + } (None, None) => Resolved::Missing, } }; @@ -358,13 +375,14 @@ impl TerminalBackend for AcpTerminalAdapter { match resolved { Resolved::Ready(snapshot) => Some(snapshot), Resolved::Missing => None, - // Live poll failed: fill output from the mirrored log so a running - // task does not report empty while the file already holds data. - Resolved::FromLog(mut snapshot, output_file) => { - if let Ok(logged) = tokio::fs::read_to_string(&output_file).await - && !logged.is_empty() - { - snapshot.output = logged; + Resolved::FromLog { + mut snapshot, + output_file, + limit, + } => { + if let Some(tail) = read_log_tail(&output_file, limit).await { + snapshot.output = tail.text; + snapshot.truncated = tail.truncated; } Some(snapshot) } @@ -497,20 +515,6 @@ mod tests { } } - #[test] - fn to_snapshot_preserves_description() { - let mut task = make_tracked_task("sleep 1"); - task.description = Some("build frontend".to_string()); - let snap = task.to_snapshot("t-1", out("ok", Some(0), None)); - assert_eq!(snap.description.as_deref(), Some("build frontend")); - assert_eq!(snap.task_id, "t-1"); - assert_eq!(snap.exit_code, Some(0)); - - let bare = make_tracked_task("sleep 1"); - let snap = bare.to_snapshot("t-2", out("", None, None)); - assert!(snap.description.is_none()); - } - #[test] fn wrap_command_quotes_shell_metacharacters() { let cmd = wrap_command("echo 'hello world' && ls").unwrap(); @@ -537,37 +541,21 @@ mod tests { } #[test] - fn tracked_task_to_snapshot_running() { - let task = make_tracked_task("ls -la"); - let snap = task.to_snapshot("t-1", out("file1\nfile2", None, None)); + fn to_snapshot_derives_completed_and_end_time() { + let running = make_tracked_task("ls -la").to_snapshot("t-1", out("partial", None, None)); + assert!(!running.completed); + assert!(running.end_time.is_none()); - assert_eq!(snap.task_id, "t-1"); - assert_eq!(snap.command, "ls -la"); - assert_eq!(snap.cwd, "/tmp"); - assert_eq!(snap.output, "file1\nfile2"); - assert!(!snap.completed); - assert!(snap.end_time.is_none()); - assert_eq!(snap.exit_code, None); - } + // An exit code or a signal marks the snapshot complete and stamps end_time. + let exited = make_tracked_task("fast").to_snapshot("t-2", out("", Some(1), None)); + assert!(exited.completed); + assert!(exited.end_time.is_some()); + assert_eq!(exited.exit_code, Some(1)); - #[test] - fn tracked_task_to_snapshot_completed() { - let mut task = make_tracked_task("echo done"); - task.mark_completed(out("done\n", Some(0), None)); - let snap = task.to_snapshot("t-2", out("done\n", Some(0), None)); - - assert!(snap.completed); - assert!(snap.end_time.is_some()); - assert_eq!(snap.exit_code, Some(0)); - assert_eq!(snap.signal, None); - } - - #[test] - fn tracked_task_to_snapshot_completed_by_exit_code_alone() { - let task = make_tracked_task("fast cmd"); - let snap = task.to_snapshot("t-3", out("", Some(1), None)); - assert!(snap.completed); - assert!(snap.end_time.is_some()); + let signaled = + make_tracked_task("killed").to_snapshot("t-3", out("", None, Some("SIGTERM".into()))); + assert!(signaled.completed); + assert!(signaled.end_time.is_some()); } /// Scripted client side of the terminal protocol: each `terminal/output` @@ -689,4 +677,75 @@ mod tests { "line1\nline2\nline3\n" ); } + + /// A gateway whose `terminal/output` never replies, so live polls fail and + /// `get_task` exercises its offline fallback. + fn output_unavailable_gateway() -> GatewaySender { + use xai_acp_lib::AcpClientMessage; + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + if let AcpClientMessage::ReleaseTerminal(args) = msg { + let _ = args + .response_tx + .send(Ok(acp::ReleaseTerminalResponse::new())); + } + } + }); + GatewaySender::new(tx) + } + + fn insert_task(adapter: &AcpTerminalAdapter, task_id: &str, task: TrackedTask) { + adapter + .tasks + .lock() + .unwrap() + .insert(task_id.to_string(), task); + } + + #[tokio::test] + async fn get_task_completed_keeps_completion_buffer_over_log() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("done.log"); + tokio::fs::write(&log, "stale mirrored bytes") + .await + .unwrap(); + + let adapter = + AcpTerminalAdapter::new(output_unavailable_gateway(), acp::SessionId::new("s")); + let mut task = TrackedTask { + output_file: log, + ..Default::default() + }; + task.mark_completed(out("authoritative output", Some(0), None)); + insert_task(&adapter, "t-done", task); + + let snap = adapter.get_task("t-done").await.unwrap(); + assert_eq!(snap.output, "authoritative output"); + assert!(snap.completed); + } + + #[tokio::test] + async fn get_task_running_fills_output_from_log() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("run.log"); + tokio::fs::write(&log, "live streamed bytes").await.unwrap(); + + let adapter = + AcpTerminalAdapter::new(output_unavailable_gateway(), acp::SessionId::new("s")); + insert_task( + &adapter, + "t-run", + TrackedTask { + output_file: log, + output_byte_limit: 1024, + ..Default::default() + }, + ); + + let snap = adapter.get_task("t-run").await.unwrap(); + assert_eq!(snap.output, "live streamed bytes"); + assert!(!snap.completed); + assert!(!snap.truncated); + } } diff --git a/crates/codegen/xai-grok-shell/src/terminal/output_recorder.rs b/crates/codegen/xai-grok-shell/src/terminal/output_recorder.rs index b847e32..473f37c 100644 --- a/crates/codegen/xai-grok-shell/src/terminal/output_recorder.rs +++ b/crates/codegen/xai-grok-shell/src/terminal/output_recorder.rs @@ -146,6 +146,40 @@ fn largest_overlap( overlap } +pub(crate) struct LogTail { + pub(crate) text: String, + pub(crate) truncated: bool, +} + +pub(crate) async fn read_log_tail(path: &std::path::Path, limit: usize) -> Option { + use tokio::io::{AsyncReadExt, AsyncSeekExt}; + let mut file = tokio::fs::File::open(path).await.ok()?; + let len = file.seek(std::io::SeekFrom::End(0)).await.ok()?; + let back = len.min(limit as u64); + let truncated = back < len; + file.seek(std::io::SeekFrom::End(-i64::try_from(back).ok()?)) + .await + .ok()?; + let mut buf = Vec::with_capacity(back as usize); + file.take(back).read_to_end(&mut buf).await.ok()?; + let head = buf + .iter() + .position(|&b| b & 0xC0 != 0x80) + .unwrap_or(buf.len()); + let text = match std::str::from_utf8(&buf[head..]) { + Ok(s) => s, + Err(e) => std::str::from_utf8(&buf[head..head + e.valid_up_to()]) + .expect("valid_up_to() yields a valid UTF-8 prefix"), + }; + if text.is_empty() { + return None; + } + Some(LogTail { + text: text.to_owned(), + truncated, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -233,4 +267,28 @@ mod tests { "line1\nline2\nline3\n" ); } + + #[tokio::test] + async fn read_log_tail_drops_leading_partial_char() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("lead.log"); + // "€ab" is [E2 82 AC 61 62]; a 3-byte limit cuts inside the euro sign. + tokio::fs::write(&path, "€ab").await.unwrap(); + let tail = read_log_tail(&path, 3).await.unwrap(); + assert_eq!(tail.text, "ab"); + assert!(tail.truncated); + } + + #[tokio::test] + async fn read_log_tail_drops_trailing_partial_char() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("trail.log"); + // File ends mid-character: "ab" then the first two bytes of the euro sign. + tokio::fs::write(&path, [b'a', b'b', 0xE2, 0x82]) + .await + .unwrap(); + let tail = read_log_tail(&path, 1024).await.unwrap(); + assert_eq!(tail.text, "ab"); + assert!(!tail.truncated); + } } diff --git a/crates/codegen/xai-grok-shell/src/util/config/mcp.rs b/crates/codegen/xai-grok-shell/src/util/config/mcp.rs index cd1904b..f2963dd 100644 --- a/crates/codegen/xai-grok-shell/src/util/config/mcp.rs +++ b/crates/codegen/xai-grok-shell/src/util/config/mcp.rs @@ -1,7 +1,6 @@ use agent_client_protocol as acp; use anyhow::Result; use indexmap::IndexMap; -use serde::Deserialize; use std::collections::HashMap; use std::path::PathBuf; use toml::Value as TomlValue; @@ -13,9 +12,10 @@ pub use xai_grok_mcp::oauth_config::{McpOAuthConfig, McpOAuthConfigMap}; // MCP server config value types extracted to `xai-grok-config-types` (config // dependency inversion); re-exported so `crate::util::config::*` paths keep working. pub use xai_grok_config_types::{ - McpJsonOAuthBlock, McpPreferenceSource, McpPreferencesFile, McpServerConfig, - McpServerPreferences, McpServerTransportConfig, McpSetupConfig, McpSetupDerivedValue, - McpSetupField, McpSetupFieldType, McpSetupOption, McpSetupResolution, + KNOWN_MCP_SERVER_FIELDS, McpJsonOAuthBlock, McpPreferenceSource, McpPreferencesFile, + McpServerConfig, McpServerConfigProblem, McpServerPreferences, McpServerProblemSeverity, + McpServerTransportConfig, McpSetupConfig, McpSetupDerivedValue, McpSetupField, + McpSetupFieldType, McpSetupOption, McpSetupResolution, }; // Permission-policy value types likewise extracted; re-exported to keep paths stable. pub use xai_grok_config_types::{ @@ -557,10 +557,8 @@ pub fn collect_mcp_setup_configs( if let Some(ref inline_value) = plugin.inline_mcp_servers { let normalized = xai_grok_agent::plugins::manifest::normalize_inline_mcp_servers(inline_value); - if let Ok(config) = serde_json::from_value::(normalized) { - for (name, server) in config.mcp_servers { - plugin_configs.entry(name).or_insert(server); - } + for (name, server) in mcp_config_from_json_value(&normalized).mcp_servers { + plugin_configs.entry(name).or_insert(server); } } for (name, config) in plugin_configs { @@ -862,21 +860,112 @@ pub fn load_mcp_server_configs() -> IndexMap { parse_mcp_servers_from_toml(&root) } -fn parse_mcp_servers_from_toml(root: &TomlValue) -> IndexMap { - let TomlValue::Table(table) = root else { - return IndexMap::new(); +/// Deserialize one `[mcp_servers.]` table, also returning any unrecognized keys. +fn deserialize_mcp_server_config( + value: &TomlValue, +) -> Result<(McpServerConfig, Vec), String> { + let unknown_fields = value.as_table().map_or_else(Vec::new, |table| { + table + .keys() + .filter(|field| !KNOWN_MCP_SERVER_FIELDS.contains(&field.as_str())) + .cloned() + .collect() + }); + let config = toml::Value::try_into::(value.clone()) + .map_err(|error| error.to_string())?; + Ok((config, unknown_fields)) +} + +/// Turn a failed `[mcp_servers.]` entry into an actionable problem. The +/// transport-less case is steered to `disabled_mcp_servers`, Grok's real +/// disable mechanism. +fn diagnose_invalid_entry(name: &str, value: &TomlValue, error: &str) -> McpServerConfigProblem { + let has_command = value.get("command").is_some(); + let has_url = value.get("url").is_some(); + let message = if !has_command && !has_url { + format!( + "`mcp_servers.{name}` has no transport. To run it, set `command = \"...\"` or \ + `url = \"...\"`. To turn it off, add \"{name}\" to `disabled_mcp_servers` instead of \ + leaving an entry with no transport. \ + See ~/.grok/docs/user-guide/07-mcp-servers.md" + ) + } else { + format!( + "`mcp_servers.{name}` has an invalid transport: {error}. \ + See ~/.grok/docs/user-guide/07-mcp-servers.md" + ) }; - let Some(TomlValue::Table(mcp_servers)) = table.get("mcp_servers") else { - return IndexMap::new(); + McpServerConfigProblem { + server: name.to_string(), + field: None, + severity: McpServerProblemSeverity::Error, + message, + } +} + +pub(crate) struct ParsedMcpServers { + pub servers: IndexMap, + pub problems: Vec, +} + +/// Parse `[mcp_servers.*]` without ever failing the whole config: valid servers +/// load, invalid entries are reported (GBT-4128). +pub(crate) fn parse_mcp_servers_with_problems(root: &TomlValue) -> ParsedMcpServers { + let mut servers = IndexMap::new(); + let mut problems = Vec::new(); + + let entries = match root { + TomlValue::Table(table) => match table.get("mcp_servers") { + Some(TomlValue::Table(mcp_servers)) => mcp_servers, + _ => return ParsedMcpServers { servers, problems }, + }, + _ => return ParsedMcpServers { servers, problems }, }; - let mut result = IndexMap::new(); - for (name, value) in mcp_servers { - if let Ok(config) = toml::Value::try_into::(value.clone()) { - result.insert(name.clone(), config); + for (name, value) in entries { + match deserialize_mcp_server_config(value) { + Ok((config, unknown_fields)) => { + for field in unknown_fields { + problems.push(McpServerConfigProblem { + server: name.clone(), + field: Some(field.clone()), + severity: McpServerProblemSeverity::Warning, + message: format!( + "`mcp_servers.{name}` has an unrecognized field `{field}`; it is \ + ignored. See ~/.grok/docs/user-guide/07-mcp-servers.md" + ), + }); + } + if config.enabled + && let Some(field) = config.blank_transport_field() + { + problems.push(McpServerConfigProblem { + server: name.clone(), + field: Some(field.to_string()), + severity: McpServerProblemSeverity::Error, + message: format!( + "`mcp_servers.{name}` is enabled but its `{field}` is blank. \ + Set a value, or add \"{name}\" to `disabled_mcp_servers` to turn it \ + off. See ~/.grok/docs/user-guide/07-mcp-servers.md" + ), + }); + continue; + } + servers.insert(name.clone(), config); + } + Err(error) => problems.push(diagnose_invalid_entry(name, value, &error)), } } - result + ParsedMcpServers { servers, problems } +} + +/// Wrapper that logs problems and returns only the valid servers. +pub(crate) fn parse_mcp_servers_from_toml(root: &TomlValue) -> IndexMap { + let ParsedMcpServers { servers, problems } = parse_mcp_servers_with_problems(root); + for problem in &problems { + tracing::warn!(server = %problem.server, "{}", problem.message); + } + servers } // ── .mcp.json support ──────────────────────────────────────────────── @@ -1061,7 +1150,7 @@ fn load_claude_json_mcp_servers_from_as_configs( return IndexMap::new(); } }; - let config: ClaudeJsonConfig = match serde_json::from_str(&content) { + let value: serde_json::Value = match serde_json::from_str(&content) { Ok(v) => v, Err(e) => { tracing::debug!( @@ -1072,6 +1161,7 @@ fn load_claude_json_mcp_servers_from_as_configs( return IndexMap::new(); } }; + let config = claude_json_mcp_from_value(&value); let mut result = IndexMap::new(); @@ -1190,20 +1280,6 @@ pub(crate) fn load_cursor_mcp_servers_as_configs( result } -/// Subset of `~/.claude.json` we care about for MCP server discovery. -/// -/// Reuses `McpConfig` for both the top-level user MCP servers and per-project -/// entries — the JSON shape (`{ "mcpServers": { ... } }`) is identical at both levels. -#[derive(Default, Deserialize)] -struct ClaudeJsonConfig { - /// User-level MCP servers (top-level `mcpServers` key). - #[serde(flatten)] - user_mcp: McpConfig, - /// Per-project entries, keyed by absolute project path. - #[serde(default)] - projects: HashMap, -} - /// Inner implementation that accepts the file path, making it testable. fn load_claude_json_mcp_servers_from( claude_json_path: &std::path::Path, @@ -1213,7 +1289,7 @@ fn load_claude_json_mcp_servers_from( Ok(c) => c, Err(_) => return vec![], }; - let config: ClaudeJsonConfig = match serde_json::from_str(&content) { + let value: serde_json::Value = match serde_json::from_str(&content) { Ok(v) => v, Err(e) => { tracing::debug!( @@ -1224,6 +1300,7 @@ fn load_claude_json_mcp_servers_from( return vec![]; } }; + let config = claude_json_mcp_from_value(&value); let sub = &crate::config::expand_env_vars_in_string; let mut servers = Vec::new(); @@ -1243,18 +1320,62 @@ fn load_claude_json_mcp_servers_from( servers } -/// Read and parse a JSON file. Returns `None` on I/O or parse errors (logged). +/// Build an `McpConfig` from a JSON value, skipping any `mcpServers` entry that +/// fails to deserialize instead of dropping the whole file. Mirrors the +/// per-entry tolerance of [`parse_mcp_servers_with_problems`] for TOML, so one +/// bad entry in a `.mcp.json` or `~/.claude.json` cannot take out its siblings. +fn mcp_config_from_json_value(value: &serde_json::Value) -> McpConfig { + let mut mcp_servers = IndexMap::new(); + if let Some(entries) = value.get("mcpServers").and_then(|v| v.as_object()) { + for (name, entry) in entries { + match serde_json::from_value::(entry.clone()) { + Ok(config) => { + mcp_servers.insert(name.clone(), config); + } + Err(error) => tracing::warn!( + server = %name, + error = %error, + "skipping invalid MCP server entry in JSON config" + ), + } + } + } + McpConfig { mcp_servers } +} + +/// Parsed `~/.claude.json` MCP view: top-level user servers plus per-project maps. +struct ClaudeJsonMcp { + user_mcp: McpConfig, + projects: HashMap, +} + +/// Build the `~/.claude.json` MCP view from a JSON value, tolerating bad entries +/// per server (see [`mcp_config_from_json_value`]). +fn claude_json_mcp_from_value(value: &serde_json::Value) -> ClaudeJsonMcp { + let user_mcp = mcp_config_from_json_value(value); + let mut projects = HashMap::new(); + if let Some(entries) = value.get("projects").and_then(|v| v.as_object()) { + for (path, project) in entries { + projects.insert(path.clone(), mcp_config_from_json_value(project)); + } + } + ClaudeJsonMcp { user_mcp, projects } +} + +/// Read and parse a JSON file. Returns `None` on I/O or top-level parse errors +/// (logged); individual bad `mcpServers` entries are skipped, not fatal. pub(crate) fn read_mcp_json(path: &std::path::Path) -> Option { let content = std::fs::read_to_string(path) .map_err(|e| { tracing::warn!(error = %e, "failed to read MCP JSON"); }) .ok()?; - serde_json::from_str(&content) + let value: serde_json::Value = serde_json::from_str(&content) .map_err(|e| { tracing::warn!(error = %e, "failed to parse MCP JSON"); }) - .ok() + .ok()?; + Some(mcp_config_from_json_value(&value)) } /// Like `load_mcp_servers_with_project` but returns raw configs without filtering by `enabled`. @@ -1295,6 +1416,21 @@ pub fn load_mcp_server_configs_with_project( servers } +/// MCP config problems across the same layers as +/// [`load_mcp_server_configs_with_project`], for `grok inspect`. +pub fn load_mcp_server_problems_with_project(cwd: &std::path::Path) -> Vec { + let mut problems = Vec::new(); + if let Ok(global_config) = crate::config::load_effective_config() { + problems.extend(parse_mcp_servers_with_problems(&global_config).problems); + } + for config_path in crate::config::find_project_configs(cwd) { + if let Ok(root) = crate::config::load_config_file(&config_path) { + problems.extend(parse_mcp_servers_with_problems(&root).problems); + } + } + problems +} + /// MCP server names with `enabled = false` in config.toml (including project overrides). pub fn disabled_mcp_server_names(cwd: &std::path::Path) -> std::collections::HashSet { let mut disabled: std::collections::HashSet = load_all_mcp_configs(cwd) @@ -1518,6 +1654,133 @@ mod tests { } /// Covers all canonical wire values plus the unknown/corrupt fallback. + #[test] + fn parse_mcp_servers_skips_unparseable_entries() { + let root = toml::from_str::( + r#" +mcp_servers.broken = "not-a-table" + +[mcp_servers.also_broken] +enabled = "yes" + +[mcp_servers.ok] +command = "echo" +args = ["hi"] +"#, + ) + .unwrap(); + let servers = parse_mcp_servers_from_toml(&root); + assert!(!servers.contains_key("broken")); + assert!(!servers.contains_key("also_broken")); + assert!(servers.contains_key("ok")); + } + + #[test] + fn parse_mcp_server_config_reports_unknown_fields() { + let value = toml::from_str::( + r#" +command = "echo" +enabeld = false +"#, + ) + .unwrap(); + let (config, unknown_fields) = deserialize_mcp_server_config(&value).unwrap(); + assert!( + config.enabled, + "the misspelled field must not silently disable the server" + ); + assert_eq!(unknown_fields, vec!["enabeld"]); + } + + #[test] + fn parse_mcp_servers_drops_transport_less_entry() { + let root = toml::from_str::( + r#" +[mcp_servers.github] +enabled = false + +[mcp_servers.linear] +command = "npx" +args = ["-y", "mcp-remote", "https://mcp.linear.app/mcp"] +"#, + ) + .unwrap(); + let ParsedMcpServers { servers, problems } = parse_mcp_servers_with_problems(&root); + assert!( + !servers.contains_key("github"), + "transport-less entry is dropped, not kept" + ); + assert!(servers.contains_key("linear")); + assert!(servers["linear"].enabled); + let problem = problems + .iter() + .find(|p| p.server == "github") + .expect("github problem reported"); + assert_eq!(problem.severity, McpServerProblemSeverity::Error); + assert!( + problem.message.contains("disabled_mcp_servers"), + "{problem:?}" + ); + } + + #[test] + fn parse_mcp_servers_rejects_enabled_without_transport() { + let root = toml::from_str::( + r#" +[mcp_servers.half] +enabled = true +"#, + ) + .unwrap(); + let ParsedMcpServers { servers, problems } = parse_mcp_servers_with_problems(&root); + assert!( + !servers.contains_key("half"), + "enabled without command/url must be dropped" + ); + assert!(problems.iter().any(|p| p.server == "half")); + } + + #[test] + fn parse_mcp_servers_rejects_blank_transport() { + let root = toml::from_str::( + r#" +[mcp_servers.blank_url] +url = " " + +[mcp_servers.blank_cmd] +command = "" +"#, + ) + .unwrap(); + let ParsedMcpServers { servers, problems } = parse_mcp_servers_with_problems(&root); + assert!(!servers.contains_key("blank_url"), "blank url dropped"); + assert!(!servers.contains_key("blank_cmd"), "blank command dropped"); + assert_eq!( + problems + .iter() + .filter(|p| p.severity == McpServerProblemSeverity::Error) + .count(), + 2, + "both blank transports reported: {problems:?}" + ); + } + + #[test] + fn json_map_skips_bad_entry_and_keeps_the_rest() { + // One transport-less entry must not drop its siblings in the same JSON + // file (.mcp.json / ~/.claude.json). + let value = serde_json::json!({ + "mcpServers": { + "bad": { "enabled": false }, + "good": { "command": "npx", "args": ["-y", "pkg"] } + } + }); + let config = mcp_config_from_json_value(&value); + assert!(!config.mcp_servers.contains_key("bad")); + assert!(config.mcp_servers.contains_key("good")); + assert!(config.mcp_servers["good"].enabled); + } + #[test] fn test_parse_mcp_servers_empty() { let root = toml::from_str::("").unwrap(); diff --git a/crates/codegen/xai-grok-shell/src/util/hooks.rs b/crates/codegen/xai-grok-shell/src/util/hooks.rs index 4dc689e..aacb737 100644 --- a/crates/codegen/xai-grok-shell/src/util/hooks.rs +++ b/crates/codegen/xai-grok-shell/src/util/hooks.rs @@ -43,7 +43,7 @@ fn include_cursor_hooks(compat: &xai_grok_tools::types::compat::CompatConfig) -> } /// Global + project hook source paths. Registry file is never a discovery -/// source; Claude/Cursor globals are appended when gates are on. +/// source; compatible vendor globals are appended when their gates are on. pub fn discover_hook_source_paths( git_root: Option<&Path>, compat: &xai_grok_tools::types::compat::CompatConfig, @@ -110,7 +110,37 @@ pub fn discover_hooks( compat: &xai_grok_tools::types::compat::CompatConfig, trusted: bool, ) -> (xai_grok_hooks::discovery::HookRegistry, Vec) { + // Read fresh each call (not cached): a mid-session `/hooks` reload must see an + // updated `config.toml` / `managed_config.toml`. This is lighter than + // `ConfigLayers::load` (only the small per-layer files, no campaigns, version + // overrides, or MDM). + let config_layers = xai_grok_config::hook_config_layers(); + assemble_hooks(&config_layers, git_root, compat, trusted) +} + +/// Pure, injectable core: combine config-layer hooks with file-source hooks and +/// dedup once. Config-layer specs are placed first so that, under the first-wins +/// dedup in [`xai_grok_hooks::discovery::registry_from_specs_deduped`], a config +/// hook wins over a byte-identical file hook. `config_layers` is a parameter (not +/// read here) so tests can drive it with hand-built layers. +pub fn assemble_hooks( + config_layers: &[xai_grok_config::HookConfigLayer], + git_root: Option<&Path>, + compat: &xai_grok_tools::types::compat::CompatConfig, + trusted: bool, +) -> (xai_grok_hooks::discovery::HookRegistry, Vec) { + let (mut specs, mut errors) = + xai_grok_hooks::config::parse_hooks_from_config_layers(config_layers); + let source_paths = discover_hook_source_paths(git_root, compat); let (global_sources, project_sources) = source_paths.as_sources(trusted); - xai_grok_hooks::discovery::load_hooks_from_sources(&global_sources, &project_sources) + let (file_specs, file_errors) = + xai_grok_hooks::discovery::collect_specs_from_sources(&global_sources, &project_sources); + specs.extend(file_specs); + errors.extend(file_errors); + + ( + xai_grok_hooks::discovery::registry_from_specs_deduped(specs), + errors, + ) } diff --git a/crates/codegen/xai-grok-shell/src/util/limits.rs b/crates/codegen/xai-grok-shell/src/util/limits.rs new file mode 100644 index 0000000..21a7c69 --- /dev/null +++ b/crates/codegen/xai-grok-shell/src/util/limits.rs @@ -0,0 +1,85 @@ +//! Startup logging of effective OS resource limits, so EMFILE/EAGAIN/OOM +//! crash reports carry the ceilings that were in effect. + +/// Emit one `startup.effective_limits` entry to the unified log. +pub fn log_effective_limits() { + xai_grok_telemetry::unified_log::info("startup.effective_limits", None, Some(gather())); +} + +fn gather() -> serde_json::Value { + serde_json::json!({ + "nofile": rlimit_pair(RlimitKind::Nofile), + "nproc": rlimit_pair(RlimitKind::Nproc), + "available_parallelism": std::thread::available_parallelism().map(usize::from).ok(), + "cgroup": cgroup_v2_limits(), + }) +} + +enum RlimitKind { + Nofile, + Nproc, +} + +/// `[soft, hard]` for the given rlimit; `RLIM_INFINITY` maps to JSON null. +#[cfg(unix)] +fn rlimit_pair(kind: RlimitKind) -> Option { + let resource = match kind { + RlimitKind::Nofile => libc::RLIMIT_NOFILE, + RlimitKind::Nproc => libc::RLIMIT_NPROC, + }; + let mut lim = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + // SAFETY: getrlimit writes only into local `lim`. + if unsafe { libc::getrlimit(resource, &mut lim) } != 0 { + return None; + } + let val = |v: libc::rlim_t| (v != libc::RLIM_INFINITY).then_some(v); + Some(serde_json::json!([val(lim.rlim_cur), val(lim.rlim_max)])) +} + +#[cfg(not(unix))] +fn rlimit_pair(_kind: RlimitKind) -> Option { + None +} + +/// Best-effort cgroup v2 pids/memory ceilings — the limits behind EAGAIN +/// thread-spawn failures and memcg OOM kills on shared hosts. `None` on any +/// read error or non-cgroup-v2 environment. +#[cfg(target_os = "linux")] +fn cgroup_v2_limits() -> Option { + let cgroup = std::fs::read_to_string("/proc/self/cgroup").ok()?; + // cgroup v2 unified hierarchy line: "0::". + let path = cgroup.lines().find_map(|l| l.strip_prefix("0::"))?.trim(); + let read = |f: &str| { + std::fs::read_to_string(format!("/sys/fs/cgroup{path}/{f}")) + .ok() + .map(|s| s.trim().to_owned()) + }; + Some(serde_json::json!({ + "pids_current": read("pids.current"), + "pids_max": read("pids.max"), + "memory_current": read("memory.current"), + "memory_max": read("memory.max"), + })) +} + +#[cfg(not(target_os = "linux"))] +fn cgroup_v2_limits() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::gather; + + #[test] + #[cfg(unix)] + fn gather_reports_rlimits_and_parallelism() { + let v = gather(); + assert!(v["nofile"].is_array(), "nofile missing: {v}"); + assert!(v["nproc"].is_array(), "nproc missing: {v}"); + assert!(v["available_parallelism"].is_u64(), "parallelism: {v}"); + } +} diff --git a/crates/codegen/xai-grok-shell/src/util/mod.rs b/crates/codegen/xai-grok-shell/src/util/mod.rs index c48a0d2..de0f0cf 100644 --- a/crates/codegen/xai-grok-shell/src/util/mod.rs +++ b/crates/codegen/xai-grok-shell/src/util/mod.rs @@ -1,6 +1,7 @@ pub mod config; pub mod grok_auth_credentials; pub mod hooks; +pub mod limits; pub(crate) mod subprocess; pub(crate) mod user_identity; diff --git a/crates/codegen/xai-grok-shell/tests/team_managed_config.rs b/crates/codegen/xai-grok-shell/tests/team_managed_config.rs deleted file mode 100644 index 6362b9d..0000000 --- a/crates/codegen/xai-grok-shell/tests/team_managed_config.rs +++ /dev/null @@ -1,2399 +0,0 @@ -//! End-to-end client tests for team-OAuth managed config against a mock -//! deployment-config endpoint. Proxy-side resolution is unit-tested in -//! the cli-chat-proxy deployment-config route. -//! -//! Every test here MUST be `#[serial]`: they share one process-global -//! `GROK_HOME` (the `grok_home` `OnceLock` allows a single value per process) -//! and mutate that directory + process env, so concurrent tests would race. - -use std::io::{BufRead, BufReader, Write}; -use std::net::TcpListener; -use std::path::PathBuf; -use std::sync::{Arc, Mutex, OnceLock}; - -use serial_test::serial; -use xai_grok_shell::config::ServingIdentity; -use xai_grok_test_support::spawn_counting_server; - -/// The serving identity for a team id (the staleness checks key on this). -fn team_identity(id: &str) -> ServingIdentity { - ServingIdentity::Team(id.to_owned()) -} - -/// Shared temp dir used as GROK_HOME for the whole test binary (the grok_home -/// `OnceLock` only allows one value per process). Also scrubs/installs the env -/// this suite depends on, before any test thread reads it. -fn test_home() -> &'static PathBuf { - static HOME: OnceLock = OnceLock::new(); - HOME.get_or_init(|| { - let path = tempfile::TempDir::new().unwrap().keep(); - // SAFETY: set once at init before other threads read the vars. - unsafe { - std::env::set_var("GROK_HOME", &path); - // Ambient env must not shadow the scenarios under test: a real - // deployment key, a managed-config opt-out, or a proxy that would - // intercept the 127.0.0.1 mocks. - for var in [ - "GROK_DEPLOYMENT_KEY", - "GROK_MANAGED_CONFIG", - "GROK_DEPLOYMENT_CONFIG_REFRESH_INTERVAL_SECS", - "GROK_DEPLOYMENT_CONFIG_CACHE_TTL_SECS", - "HTTP_PROXY", - "HTTPS_PROXY", - "ALL_PROXY", - "http_proxy", - "https_proxy", - "all_proxy", - ] { - std::env::remove_var(var); - } - // Real exponential backoff would add seconds per retry test. - std::env::set_var("GROK_DEPLOYMENT_CONFIG_BACKOFF_MS", "10"); - } - path - }) -} - -fn reset(home: &std::path::Path) { - for f in [ - "config.toml", - "auth.json", - "managed_config.toml", - "requirements.toml", - "managed_config.sig.json", - "managed_config_cache.json", - "managed_config.lock", - ] { - let _ = std::fs::remove_file(home.join(f)); - } -} - -/// Read one HTTP request's header block (up to the blank line) and return the -/// `Authorization` header value, if any. Header-boundary-safe, unlike a single -/// fixed-size `read()`. -fn read_request_auth(stream: &mut std::net::TcpStream) -> Option { - let mut reader = BufReader::new(stream); - let mut auth = None; - loop { - let mut line = String::new(); - if reader.read_line(&mut line).unwrap_or(0) == 0 { - return auth; - } - let line = line.trim_end(); - if line.is_empty() { - return auth; - } - if let Some((name, value)) = line.split_once(':') - && name.eq_ignore_ascii_case("authorization") - { - auth = Some(value.trim().to_string()); - } - } -} - -/// Mock deployment-config server serving `body` to every request. Returns the -/// URL and the `Authorization` header of every request in order. -fn spawn_mock(body: String) -> (String, Arc>>) { - let (url, _count, auths) = spawn_mock_seq(vec![(200, body)]); - (url, auths) -} - -/// Like [`spawn_mock_seq`] but sleeps `delay` before each response (for mid-fetch races). -fn spawn_mock_delayed(body: String, delay: std::time::Duration) -> MockHandle { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = listener.local_addr().unwrap(); - let count: Arc> = Arc::new(Mutex::new(0)); - let counter = count.clone(); - let auths: Arc>> = Arc::new(Mutex::new(Vec::new())); - let seen_auths = auths.clone(); - std::thread::spawn(move || { - for stream in listener.incoming() { - let Ok(mut stream) = stream else { continue }; - if let Some(auth) = read_request_auth(&mut stream) { - seen_auths.lock().unwrap().push(auth); - } - { - *counter.lock().unwrap() += 1; - } - std::thread::sleep(delay); - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - let _ = stream.write_all(response.as_bytes()); - } - }); - (format!("http://{addr}/v1/deployment-config"), count, auths) -} - -/// `(url, request_count, authorization_headers_in_order)`. -type MockHandle = (String, Arc>, Arc>>); - -/// Mock server that serves a sequence of `(status, body)` responses — response -/// `i` for request `i`, clamping to the last. The handle's request counter -/// backs retry/fail-fast assertions; the auth log backs credential-fallback -/// assertions. -fn spawn_mock_seq(responses: Vec<(u16, String)>) -> MockHandle { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = listener.local_addr().unwrap(); - let count: Arc> = Arc::new(Mutex::new(0)); - let counter = count.clone(); - let auths: Arc>> = Arc::new(Mutex::new(Vec::new())); - let seen_auths = auths.clone(); - std::thread::spawn(move || { - for stream in listener.incoming() { - let Ok(mut stream) = stream else { continue }; - if let Some(auth) = read_request_auth(&mut stream) { - seen_auths.lock().unwrap().push(auth); - } - let i = { - let mut c = counter.lock().unwrap(); - let idx = *c; - *c += 1; - idx - }; - let (status, body) = responses - .get(i) - .or_else(|| responses.last()) - .cloned() - .unwrap_or((200, "{}".to_string())); - let reason = match status { - 200 => "OK", - 401 => "Unauthorized", - 500 => "Internal Server Error", - _ => "Status", - }; - let resp = format!( - "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - let _ = stream.write_all(resp.as_bytes()); - let _ = stream.flush(); - } - }); - (format!("http://{addr}/deployment/config"), count, auths) -} - -/// Mock that abruptly closes its first `close_first` connections right after -/// reading the request — simulating a stale/poisoned keep-alive connection the -/// client reused — then serves `body` (HTTP 200) on every later connection. The -/// counter records accepted connections, backing the retry assertion. -fn spawn_mock_closing_first(close_first: usize, body: String) -> (String, Arc>) { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = listener.local_addr().unwrap(); - let count: Arc> = Arc::new(Mutex::new(0)); - let counter = count.clone(); - std::thread::spawn(move || { - for stream in listener.incoming() { - let Ok(mut stream) = stream else { continue }; - // Read the request first so the abort lands mid-response ("connection - // closed before message completed"), not as a connect/write failure. - let _ = read_request_auth(&mut stream); - let i = { - let mut c = counter.lock().unwrap(); - let idx = *c; - *c += 1; - idx - }; - if i < close_first { - drop(stream); - continue; - } - let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - let _ = stream.write_all(resp.as_bytes()); - let _ = stream.flush(); - } - }); - (format!("http://{addr}/deployment/config"), count) -} - -/// Mock that, for its first `truncate_first` connections, writes a valid status line -/// + headers with an OVERSIZED `Content-Length` then closes WITHOUT the body — so the -/// client's body read fails mid-body ("connection closed before message completed", a -/// `reqwest` body-phase error, NOT a decode error). Every later connection serves the -/// full valid `body` (HTTP 200). The counter records accepted connections, backing the -/// retry assertion. -fn spawn_mock_truncating_body_first( - truncate_first: usize, - body: String, -) -> (String, Arc>) { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = listener.local_addr().unwrap(); - let count: Arc> = Arc::new(Mutex::new(0)); - let counter = count.clone(); - std::thread::spawn(move || { - for stream in listener.incoming() { - let Ok(mut stream) = stream else { continue }; - // Read the request first so the abort lands in the body phase, not as a - // connect/write failure. - let _ = read_request_auth(&mut stream); - let i = { - let mut c = counter.lock().unwrap(); - let idx = *c; - *c += 1; - idx - }; - if i < truncate_first { - // Promise 100000 body bytes, send none, then drop: the client reads the - // headers fine but fails collecting the body. - let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100000\r\nConnection: close\r\n\r\n"; - let _ = stream.write_all(headers.as_bytes()); - let _ = stream.flush(); - drop(stream); - continue; - } - let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - let _ = stream.write_all(resp.as_bytes()); - let _ = stream.flush(); - } - }); - (format!("http://{addr}/deployment/config"), count) -} - -/// Write a `config.toml` that routes the managed-config fetch at the mock. -fn write_config(home: &std::path::Path, managed_config_url: &str) { - std::fs::write( - home.join("config.toml"), - format!("[endpoints]\nmanaged_config_url = \"{managed_config_url}\"\n"), - ) - .unwrap(); -} - -/// Write an `auth.json` with a team OAuth principal under the active scope. -fn write_team_auth(home: &std::path::Path, team_id: &str) { - write_team_auth_expiry(home, team_id, "2099-01-01T00:00:00Z"); -} - -/// Like [`write_team_auth`] but with an explicit `expires_at`, so tests can -/// simulate a routine cold-start where the persisted access token is expired. -fn write_team_auth_expiry(home: &std::path::Path, team_id: &str, expires_at: &str) { - let scope = xai_grok_shell::auth::GrokComConfig::default().auth_scope(); - let auth = serde_json::json!({ - scope: { - "key": "team-session-token", - "auth_mode": "oidc", - "create_time": "2026-01-01T00:00:00Z", - "expires_at": expires_at, - "user_id": "user-1", - "principal_type": "Team", - "team_id": team_id, - } - }); - std::fs::write(home.join("auth.json"), auth.to_string()).unwrap(); -} - -/// Write an `auth.json` with an EXPIRED `external`-mode team principal, so a configured refresher -/// drives `AuthManager::auth()`. Models the cold-start where the persisted token is expired but refreshable. -fn write_expired_external_team_auth(home: &std::path::Path, team_id: &str) { - let scope = xai_grok_shell::auth::GrokComConfig::default().auth_scope(); - let auth = serde_json::json!({ - scope: { - "key": "stale-team-token", - "auth_mode": "external", - "create_time": "2026-01-01T00:00:00Z", - "expires_at": PAST, - "user_id": "user-1", - "principal_type": "Team", - "team_id": team_id, - "refresh_token": "rt-team", - } - }); - std::fs::write(home.join("auth.json"), auth.to_string()).unwrap(); -} - -const FAR_FUTURE: &str = "2099-01-01T00:00:00Z"; -const PAST: &str = "2000-01-01T00:00:00Z"; - -const TEAM_MANAGED: &str = "[[marketplace.sources]]\nname = \"internal\"\ngit = \"https://github.com/example/plugin-marketplace-internal\"\n"; -const TEAM_REQUIREMENTS: &str = - "[marketplace]\nallowlist = [\"https://github.com/example/plugin-marketplace-internal\"]\n"; - -fn team_config_body() -> String { - serde_json::json!({ - "deployment_id": serde_json::Value::Null, - "team_id": "team-007", - "managed_config": TEAM_MANAGED, - "requirements": TEAM_REQUIREMENTS, - }) - .to_string() -} - -#[tokio::test] -#[serial] -async fn team_sync_writes_files() { - let home = test_home().clone(); - reset(&home); - - let (url, auths) = spawn_mock(team_config_body()); - write_config(&home, &url); - write_team_auth(&home, "team-007"); - - let wrote = xai_grok_shell::managed_config::sync() - .await - .expect("sync should succeed"); - assert!(wrote, "expected team config to be written"); - - assert_eq!( - auths.lock().unwrap().last().map(String::as_str), - Some("Bearer team-session-token"), - "client must authenticate with the team session token" - ); - - let managed = std::fs::read_to_string(home.join("managed_config.toml")).unwrap(); - assert!( - managed.contains("plugin-marketplace-internal"), - "managed_config should contain the team marketplace source: {managed}" - ); - - let requirements = std::fs::read_to_string(home.join("requirements.toml")).unwrap(); - assert!( - requirements.contains("allowlist"), - "requirements should contain the enforced allowlist: {requirements}" - ); -} - -/// A directory squatting at the MARKER path must not permanently disarm the staleness -/// detector: the atomic marker write would fail onto it on every sync, forever. The -/// locked apply clears the squat (same rule as the sidecar) and records the sync. -#[tokio::test] -#[serial] -async fn marker_dir_squat_is_cleared_and_marker_written() { - let home = test_home().clone(); - reset(&home); - - let (url, _auths) = spawn_mock(team_config_body()); - write_config(&home, &url); - write_team_auth(&home, "team-007"); - - // Dir-squat the marker (with a child, like a real squat). - let marker_path = home.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE); - std::fs::create_dir(&marker_path).unwrap(); - std::fs::write(marker_path.join("junk"), "x").unwrap(); - - let wrote = xai_grok_shell::managed_config::sync() - .await - .expect("sync should succeed"); - assert!( - wrote, - "the policy files are written despite the marker squat" - ); - - assert!( - marker_path.is_file(), - "the apply must replace the squatting directory with the marker FILE" - ); - let marker = std::fs::read_to_string(&marker_path).unwrap(); - let v: serde_json::Value = serde_json::from_str(&marker).unwrap(); - assert_eq!( - v["principal"].as_str(), - Some("team-007"), - "the recorded marker must describe this sync: {marker}" - ); -} - -/// A whitespace-padded `team_id` in `auth.json` is one identity end-to-end: the serving -/// identity and the recorded marker are trimmed, and re-syncing with the padded id is the -/// same tenant (no eviction, no confirmed switch). -#[tokio::test] -#[serial] -async fn padded_team_id_is_one_identity() { - let home = test_home().clone(); - reset(&home); - - let (url, _auths) = spawn_mock(team_config_body()); - write_config(&home, &url); - write_team_auth(&home, " team-007 "); - - assert_eq!( - xai_grok_shell::managed_config::current_serving_identity(), - team_identity("team-007"), - "the serving identity must be the trimmed team id" - ); - xai_grok_shell::managed_config::sync() - .await - .expect("sync should succeed"); - let marker = - std::fs::read_to_string(home.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE)).unwrap(); - let v: serde_json::Value = serde_json::from_str(&marker).unwrap(); - assert_eq!( - v["principal"].as_str(), - Some("team-007"), - "the marker stores the trimmed identity: {marker}" - ); - assert_eq!( - xai_grok_config::confirmed_team_switch("team-007"), - None, - "padding is not a tenant switch" - ); -} - -/// Switching the active team must not keep enforcing the prior team's policy: after B syncs, -/// A's artifacts are evicted and the marker records B served nothing. Fail-open. -#[tokio::test] -#[serial] -async fn team_switch_evicts_prior_teams_policy() { - let home = test_home().clone(); - reset(&home); - - // Team A serves both managed_config and requirements. A leftover sidecar (from an - // earlier signing build; verification is inactive here) must also be evicted, or a - // later signing build would read A's foreign-bound sidecar against B's identity. - let (url_a, _auths_a) = spawn_mock(team_config_body()); - write_config(&home, &url_a); - write_team_auth(&home, "team-a"); - xai_grok_shell::managed_config::sync() - .await - .expect("team A sync should succeed"); - assert!(home.join("requirements.toml").exists()); - assert!(home.join("managed_config.toml").exists()); - std::fs::write(home.join("managed_config.sig.json"), "{}").unwrap(); - - // Switch to team B, whose server returns a row (team_id) but no artifacts. - let body_b = serde_json::json!({ - "deployment_id": serde_json::Value::Null, - "team_id": "team-b", - "managed_config": serde_json::Value::Null, - "requirements": serde_json::Value::Null, - }) - .to_string(); - let (url_b, _auths_b) = spawn_mock(body_b); - write_config(&home, &url_b); - write_team_auth(&home, "team-b"); - - let wrote = xai_grok_shell::managed_config::sync() - .await - .expect("team B sync must not fail the session"); - assert!(!wrote, "team B serves no artifacts, so nothing is written"); - - // Team A's enforced policy is gone — team B does not inherit it. - assert!( - !home.join("requirements.toml").exists(), - "team A's requirements must be evicted on the switch to team B" - ); - assert!( - !home.join("managed_config.toml").exists(), - "team A's managed_config must be evicted on the switch to team B" - ); - assert!( - !home.join("managed_config.sig.json").exists(), - "team A's stale sidecar must be evicted on the switch to team B" - ); - - // The marker is now team B's and must not claim B served A's artifacts. - let marker = std::fs::read_to_string(home.join("managed_config_cache.json")).unwrap(); - let v: serde_json::Value = serde_json::from_str(&marker).unwrap(); - assert_eq!( - v["principal"].as_str(), - Some("team-b"), - "marker must rebind to team B: {marker}" - ); - assert_eq!( - v["had_requirements"].as_bool(), - Some(false), - "marker must not claim team B served requirements: {marker}" - ); - assert_eq!( - v["had_managed_config"].as_bool(), - Some(false), - "marker must not claim team B served managed_config: {marker}" - ); - - // Team B's cache reads fresh + identity-matched (no missing-artifact stale). - assert!(!xai_grok_shell::config::is_managed_config_stale_for( - &team_identity("team-b") - )); -} - -/// An artifact the server stops serving is removed on the next sync (disk converges -/// to the served set), and the marker stops claiming it — a withdrawn policy must not -/// keep enforcing from a stale file. -#[tokio::test] -#[serial] -async fn withdrawn_artifact_is_removed_on_next_sync() { - let home = test_home().clone(); - reset(&home); - - // Sync 1: both artifacts served. - let (url_full, _a) = spawn_mock(team_config_body()); - write_config(&home, &url_full); - write_team_auth(&home, "team-007"); - xai_grok_shell::managed_config::sync() - .await - .expect("initial sync should succeed"); - assert!(home.join("requirements.toml").exists()); - - // Sync 2: same team, requirements withdrawn. - let body = serde_json::json!({ - "deployment_id": serde_json::Value::Null, - "team_id": "team-007", - "managed_config": TEAM_MANAGED, - "requirements": serde_json::Value::Null, - }) - .to_string(); - let (url_partial, _a2) = spawn_mock(body); - write_config(&home, &url_partial); - let wrote = xai_grok_shell::managed_config::sync() - .await - .expect("second sync should succeed"); - assert!(wrote, "removing the withdrawn artifact is a change"); - - assert!( - home.join("managed_config.toml").exists(), - "the still-served artifact stays" - ); - assert!( - !home.join("requirements.toml").exists(), - "the withdrawn artifact is removed" - ); - let marker = std::fs::read_to_string(home.join("managed_config_cache.json")).unwrap(); - let v: serde_json::Value = serde_json::from_str(&marker).unwrap(); - assert_eq!( - v["had_requirements"].as_bool(), - Some(false), - "the marker stops claiming the withdrawn artifact: {marker}" - ); - assert!( - !xai_grok_shell::config::is_managed_config_stale_for(&team_identity("team-007")), - "the converged cache is not stale" - ); -} - -/// An empty dk response (`{}`) with a team signed in falls through to the team WITHOUT -/// applying: applying converges disk to the served (empty) set, which would delete the -/// team's files right before the team apply — observable when the team fetch then fails. -#[tokio::test] -#[serial] -async fn empty_dk_response_with_failing_team_leaves_team_policy_intact() { - let home = test_home().clone(); - reset(&home); - - // Seed the team's policy files + marker. - let (url, _a) = spawn_mock(team_config_body()); - write_config(&home, &url); - write_team_auth(&home, "team-007"); - xai_grok_shell::managed_config::sync() - .await - .expect("team seed sync should succeed"); - assert!(home.join("requirements.toml").exists()); - - // dk serves an empty row; the team fetch then fails (5xx for every retry). - let (url2, _c, auths) = spawn_mock_seq(vec![(200, "{}".into()), (500, "boom".into())]); - std::fs::write( - home.join("config.toml"), - format!("[endpoints]\nmanaged_config_url = \"{url2}\"\ndeployment_key = \"dep-key\"\n"), - ) - .unwrap(); - - let err = xai_grok_shell::managed_config::sync() - .await - .expect_err("the team fetch fails after the dk fallthrough"); - assert!(err.is_retryable(), "5xx is a transient failure: {err}"); - assert_eq!( - auths.lock().unwrap().first().map(String::as_str), - Some("Bearer dep-key"), - "the dk was consulted first" - ); - - assert!( - home.join("requirements.toml").exists() && home.join("managed_config.toml").exists(), - "the empty dk body must not be applied (it would delete the team's files)" - ); - let marker = std::fs::read_to_string(home.join("managed_config_cache.json")).unwrap(); - let v: serde_json::Value = serde_json::from_str(&marker).unwrap(); - assert_eq!( - v["principal"].as_str(), - Some("team-007"), - "the failed sync must not rewrite the marker: {marker}" - ); -} - -/// A served-then-deleted artifact reads stale for the active identity; the session-start refresh refetches it. -#[tokio::test] -#[serial] -async fn served_then_deleted_refetches_best_effort() { - let home = test_home().clone(); - reset(&home); - - let (url, _auths) = spawn_mock(team_config_body()); - write_config(&home, &url); - write_team_auth(&home, "team-007"); - xai_grok_shell::managed_config::sync() - .await - .expect("initial sync should succeed"); - assert!(home.join("requirements.toml").exists()); - assert!( - !xai_grok_shell::config::is_managed_config_stale_for(&team_identity("team-007")), - "a fresh, identity-matched, complete cache is not stale" - ); - - // Tamper: delete the served file but keep the fresh marker. - std::fs::remove_file(home.join("requirements.toml")).unwrap(); - assert!( - xai_grok_shell::config::is_managed_config_stale_for(&team_identity("team-007")), - "a served-but-now-missing artifact must read stale" - ); - // Identity mismatch also reads stale (team switch). - assert!(xai_grok_shell::config::is_managed_config_stale_for( - &team_identity("team-other") - )); - - // Best-effort refresh restores it; the session is never refused. Here the on-disk token is unexpired - // (cache hit). The expired-refreshable path is covered by `expired_refreshable_team_token_heals_after_auth_refresh`. - let auth_manager = std::sync::Arc::new(xai_grok_shell::auth::AuthManager::new( - &home, - xai_grok_shell::auth::GrokComConfig::default(), - )); - xai_grok_shell::managed_config::ensure_managed_policy_present(&auth_manager).await; - assert!( - home.join("requirements.toml").exists(), - "the best-effort refresh restored the deleted artifact" - ); - assert!(!xai_grok_shell::config::is_managed_config_stale_for( - &team_identity("team-007") - )); -} - -/// An expired-but-refreshable team token with a served-then-deleted artifact must heal at session start. -/// The heal drives `auth()` first so the refreshed principal can refetch; else the expiry filters drop it unhealed. -#[tokio::test] -#[serial] -async fn expired_refreshable_team_token_heals_after_auth_refresh() { - let home = test_home().clone(); - reset(&home); - - let (url, auths) = spawn_mock(team_config_body()); - // Point both the managed-config fetch and the proxy (post-refresh `/user`) at the mock — no production calls. - let base = url.trim_end_matches("/deployment/config"); - std::fs::write( - home.join("config.toml"), - format!( - "[endpoints]\nmanaged_config_url = \"{url}\"\ncli_chat_proxy_base_url = \"{base}\"\n" - ), - ) - .unwrap(); - - // Establish a served, identity-matched cache (writes the sync marker). - write_team_auth(&home, "team-007"); - xai_grok_shell::managed_config::sync() - .await - .expect("initial sync should succeed"); - assert!(home.join("requirements.toml").exists()); - - // Cold-start brick: expired-but-refreshable on-disk token, served requirements.toml gone but still in the marker. - write_expired_external_team_auth(&home, "team-007"); - std::fs::remove_file(home.join("requirements.toml")).unwrap(); - assert!( - !xai_grok_shell::managed_config::has_principal(), - "the expired token leaves no eligible managed principal — the expiry-filtered heal path can't see it (the brick)" - ); - assert!( - xai_grok_shell::config::is_managed_config_stale_for(&team_identity("team-007")), - "a served-but-now-missing artifact reads stale" - ); - - // A real AuthManager whose refresher mints a fresh team token, persisted by `auth()`. - let auth_manager = std::sync::Arc::new(xai_grok_shell::auth::AuthManager::new( - &home, - xai_grok_shell::auth::GrokComConfig::default(), - )); - auth_manager.configure_refresher( - Some(r#"echo '{"access_token":"refreshed-team-token","expires_in":3600}'"#.to_string()), - None, - ); - - xai_grok_shell::managed_config::ensure_managed_policy_present(&auth_manager).await; - - // The refresh re-enabled the heal: policy restored, refetched with the fresh token. - assert!( - home.join("requirements.toml").exists(), - "the token refresh let the best-effort heal restore the deleted policy" - ); - assert!( - xai_grok_shell::managed_config::has_principal(), - "the refreshed token is a live, eligible managed (team) principal" - ); - assert!( - auths - .lock() - .unwrap() - .iter() - .any(|a| a == "Bearer refreshed-team-token"), - "the heal refetch authenticated with the refreshed token" - ); -} - -/// The boundary: when no refresh can succeed (offline / dead token), the expired token still fails closed — -/// `auth()` errs, the heal doesn't run, and the deleted policy is NOT restored. Unbricks ONLY on a real refresh. -#[tokio::test] -#[serial] -async fn expired_team_token_without_successful_refresh_stays_failed_closed() { - let home = test_home().clone(); - reset(&home); - - let (url, _auths) = spawn_mock(team_config_body()); - write_config(&home, &url); - write_team_auth(&home, "team-007"); - xai_grok_shell::managed_config::sync() - .await - .expect("initial sync should succeed"); - std::fs::remove_file(home.join("requirements.toml")).unwrap(); - write_expired_external_team_auth(&home, "team-007"); - - // A refresher that always fails -> `auth()` cannot produce a principal. - let auth_manager = std::sync::Arc::new(xai_grok_shell::auth::AuthManager::new( - &home, - xai_grok_shell::auth::GrokComConfig::default(), - )); - auth_manager.configure_refresher(Some("false".to_string()), None); - - xai_grok_shell::managed_config::ensure_managed_policy_present(&auth_manager).await; - - assert!( - !home.join("requirements.toml").exists(), - "with no successful refresh the expired token cannot heal (fail-closed)" - ); -} - -/// `managed_policy_gate` refuses a managed session when its served policy was deleted and the refetch can't -/// restore it (offline); intact or config-less is allowed. Exercises the real sync → marker → gate path. -#[tokio::test] -#[serial] -async fn managed_policy_gate_fails_closed_on_deleted_policy_offline() { - let home = test_home().clone(); - reset(&home); - - // Admin opts in by serving `fail_closed = true` (server-driven; no local env). - let body = serde_json::json!({ - "deployment_id": serde_json::Value::Null, - "team_id": "team-007", - "managed_config": TEAM_MANAGED, - "requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"), - }) - .to_string(); - let (url, _auths) = spawn_mock(body); - write_config(&home, &url); - write_team_auth(&home, "team-007"); - xai_grok_shell::managed_config::sync() - .await - .expect("initial sync should succeed"); - // Intact, identity-matched policy → the gate proceeds. - assert!( - xai_grok_shell::managed_config::managed_policy_gate().is_ok(), - "an intact served policy must not be refused" - ); - - // Tamper: delete the served file but keep the marker → gate fails closed. - std::fs::remove_file(home.join("requirements.toml")).unwrap(); - assert!( - xai_grok_shell::managed_config::managed_policy_gate().is_err(), - "a served-then-deleted policy must fail closed" - ); - - // Offline: the 5xx refetch can't restore the file → gate stays fail-closed (far-future token makes auth a cache hit). - let (err_url, _c, _a) = spawn_mock_seq(vec![(500, "{}".to_string())]); - write_config(&home, &err_url); - let auth_manager = std::sync::Arc::new(xai_grok_shell::auth::AuthManager::new( - &home, - xai_grok_shell::auth::GrokComConfig::default(), - )); - xai_grok_shell::managed_config::ensure_managed_policy_present(&auth_manager).await; - assert!( - !home.join("requirements.toml").exists(), - "a failed refetch cannot restore the deleted policy" - ); - assert!( - xai_grok_shell::managed_config::managed_policy_gate().is_err(), - "still missing after a failed refetch → gate stays fail-closed" - ); - - // A config-less principal (the server served nothing) is never refused. - reset(&home); - write_config(&home, &url); - write_team_auth(&home, "team-007"); - xai_grok_shell::config::mark_managed_config_synced(xai_grok_shell::config::SyncMarker { - principal: Some("team-007"), - had_managed_config: false, - had_requirements: false, - key_fingerprint: None, - fail_closed: false, - }); - assert!( - xai_grok_shell::managed_config::managed_policy_gate().is_ok(), - "a config-less principal must not be refused" - ); -} - -/// `bootstrap` must run the fail-closed gate (it is `bootstrap`'s first step): a compromised managed policy -/// must fail the whole bootstrap closed, not just the standalone `managed_policy_gate`. Guards against a -/// refactor that drops the gate call from `bootstrap` — which the gate's own tests would not catch. -#[tokio::test] -#[serial] -async fn bootstrap_fails_closed_when_managed_policy_compromised() { - let home = test_home().clone(); - reset(&home); - - // Provision a fail_closed team install (both artifacts served), then tamper by deleting the served policy. - let body = serde_json::json!({ - "deployment_id": serde_json::Value::Null, - "team_id": "team-007", - "managed_config": TEAM_MANAGED, - "requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"), - }) - .to_string(); - let (url, _auths) = spawn_mock(body); - write_config(&home, &url); - write_team_auth(&home, "team-007"); - xai_grok_shell::managed_config::sync() - .await - .expect("initial sync should succeed"); - std::fs::remove_file(home.join("requirements.toml")).unwrap(); - - // The gate is bootstrap's first step, so it refuses before any config/model work. - let cfg = xai_grok_shell::agent::config::Config::default(); - let auth_manager = std::sync::Arc::new(xai_grok_shell::auth::AuthManager::new( - &home, - xai_grok_shell::auth::GrokComConfig::default(), - )); - // `bootstrap`'s Ok type isn't `Debug`, so match rather than `expect_err`. - let err = match xai_grok_shell::agent::init::bootstrap(&cfg, &auth_manager, None) { - Err(e) => e, - Ok(_) => { - panic!("a compromised fail_closed policy must fail bootstrap closed, but it succeeded") - } - }; - assert!( - err.contains("Managed policy is required for this account"), - "bootstrap must fail via the managed-policy gate (proves bootstrap calls it); got: {err}" - ); -} - -/// Live wiring guard: an offline `GROK_DEPLOYMENT_KEY` switch on a fail_closed install must FAIL CLOSED, else a -/// regression returning `None` silently disables deploy-key-switch detection. Same-key ALLOW checks the lib's own `blake3(KEY-AAA)` exactly. -#[tokio::test] -#[serial] -async fn managed_policy_gate_fails_closed_on_deployment_key_switch_offline() { - let home = test_home().clone(); - reset(&home); - - // Provision a fail_closed deploy install bound to key A; both artifacts written, so the only tamper signal is the key fingerprint. - let body = serde_json::json!({ - "deployment_id": "deploy-A", - "managed_config": TEAM_MANAGED, - "requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"), - }) - .to_string(); - let (url, _auths) = spawn_mock(body); - write_config(&home, &url); - - // SAFETY: #[serial] test; the env is restored before any assertion below. - unsafe { std::env::set_var("GROK_DEPLOYMENT_KEY", "KEY-AAA") }; - xai_grok_shell::managed_config::sync() - .await - .expect("deployment-key sync should record the fail_closed marker"); - - // The marker records the lib-computed fingerprint (never the raw key), full blake3 hex. - let marker = std::fs::read_to_string(home.join("managed_config_cache.json")).unwrap(); - let v: serde_json::Value = serde_json::from_str(&marker).unwrap(); - let fp_a = v["key_fingerprint"] - .as_str() - .expect("deploy-key sync records a key fingerprint") - .to_string(); - let fail_closed_recorded = v["fail_closed"].as_bool().unwrap_or(false); - - // GROK_MANAGED_CONFIG=0 disables any incidental background fetch (the gate is sync anyway). - // SAFETY: #[serial] test; restored before any assertion below. - unsafe { std::env::set_var("GROK_MANAGED_CONFIG", "0") }; - - // Same key A → matching fingerprint → ALLOW (exact-equality vs recorded blake3). - let gate_same_key = xai_grok_shell::managed_config::managed_policy_gate(); - - // Switch to a different key B → REFUSE: exercises the full offline wiring. - // SAFETY: #[serial] test; restored immediately below. - unsafe { std::env::set_var("GROK_DEPLOYMENT_KEY", "KEY-BBB") }; - let gate_switched_key = xai_grok_shell::managed_config::managed_policy_gate(); - - // SAFETY: #[serial] test; restore env BEFORE asserting so a failed assert can't leak it to later tests. - unsafe { - std::env::remove_var("GROK_DEPLOYMENT_KEY"); - std::env::remove_var("GROK_MANAGED_CONFIG"); - } - - // blake3-256 hex is exactly 64 hex chars — pins the recorded format. - assert!( - fp_a.len() == 64 && fp_a.chars().all(|c| c.is_ascii_hexdigit()), - "recorded key_fingerprint must be a full blake3 hex, not just non-empty: {marker}" - ); - assert!( - fail_closed_recorded, - "marker must record fail_closed = true: {marker}" - ); - assert!( - home.join("requirements.toml").exists() && home.join("managed_config.toml").exists(), - "both served artifacts must be present so the fingerprint is the only tamper signal" - ); - assert!( - !marker.contains("KEY-AAA"), - "the raw deployment key must never be written to disk: {marker}" - ); - assert!( - gate_same_key.is_ok(), - "same deployment key + intact fail_closed policy must be ALLOWED (proves recorded fp == fresh blake3(KEY-AAA))" - ); - assert!( - gate_switched_key.is_err(), - "a deployment-key switch (different fingerprint) on a fail_closed machine must FAIL CLOSED offline" - ); -} - -/// A leftover fail_closed marker from a prior managed stint must NOT lock out a user who has since signed out -/// (no deployment key, no team auth): the gate requires a present principal, so `managed_principal_present()` -/// short-circuits. Guards the worst-case regression — locking a normal user out of their own CLI. -#[test] -#[serial] -fn former_managed_user_signed_out_is_not_locked_out() { - let home = test_home().clone(); - reset(&home); - - // No deployment key in config, and `reset` removed auth.json → no principal. - std::fs::write(home.join("config.toml"), "[endpoints]\n").unwrap(); - // A stale, opted-in marker that reads tampered: it recorded a served requirements.toml that's now absent. - xai_grok_shell::config::mark_managed_config_synced(xai_grok_shell::config::SyncMarker { - principal: Some("team-007"), - had_managed_config: false, - had_requirements: true, - key_fingerprint: None, - fail_closed: true, - }); - - assert!( - xai_grok_shell::managed_config::managed_policy_gate().is_ok(), - "a signed-out user with a leftover fail_closed marker must not be refused (no principal to enforce)" - ); -} - -/// An unreadable auth.json makes `managed_principal_present()` fail safe to "present", but with NO fail_closed -/// marker the gate still allows — the fail-safe must never lock out a personal user who has no managed policy. -#[test] -#[serial] -fn unreadable_auth_without_marker_is_not_refused() { - let home = test_home().clone(); - reset(&home); - - std::fs::write(home.join("config.toml"), "[endpoints]\n").unwrap(); - std::fs::write(home.join("auth.json"), "{corrupt json").unwrap(); - // No managed_config_cache.json marker at all. - - assert!( - xai_grok_shell::managed_config::managed_policy_gate().is_ok(), - "unreadable auth (fail-safe present) with no fail_closed marker must not be refused" - ); -} - -/// Deploy-key online heal: a fail_closed deploy install whose served requirements.toml was deleted heals at -/// session start when the refetch succeeds, and the gate then allows — the key path's mirror of the team heal. -#[tokio::test] -#[serial] -async fn deployment_key_served_then_deleted_heals_online() { - let home = test_home().clone(); - reset(&home); - - let body = serde_json::json!({ - "deployment_id": "deploy-A", - "managed_config": TEAM_MANAGED, - "requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"), - }) - .to_string(); - let (url, _auths) = spawn_mock(body); - std::fs::write( - home.join("config.toml"), - format!("[endpoints]\nmanaged_config_url = \"{url}\"\ndeployment_key = \"KEY-AAA\"\n"), - ) - .unwrap(); - xai_grok_shell::managed_config::sync() - .await - .expect("initial deploy-key sync should succeed"); - assert!(home.join("requirements.toml").exists()); - - // Tamper: delete the served artifact (offline this would fail closed). - std::fs::remove_file(home.join("requirements.toml")).unwrap(); - - // The mock still serves, so the best-effort session-start refresh restores it; the gate then allows. - let auth_manager = std::sync::Arc::new(xai_grok_shell::auth::AuthManager::new( - &home, - xai_grok_shell::auth::GrokComConfig::default(), - )); - xai_grok_shell::managed_config::ensure_managed_policy_present(&auth_manager).await; - assert!( - home.join("requirements.toml").exists(), - "the online refetch must restore the deleted deploy-key policy" - ); - assert!( - xai_grok_shell::managed_config::managed_policy_gate().is_ok(), - "after a successful heal the deploy-key gate must allow" - ); -} - -/// A confirmed offline team switch (fail_closed team-A install, then team B signs in with no -/// network): the gate PURGES team A's now-foreign artifacts and marker, then PERMITS team B — -/// a legitimate switch is neither refused nor left running under team A's lingering policy. -#[tokio::test] -#[serial] -async fn identity_change_permits_offline_team_switch_and_purges_prior_team() { - let home = test_home().clone(); - reset(&home); - - let body = serde_json::json!({ - "deployment_id": serde_json::Value::Null, - "team_id": "team-a", - "managed_config": TEAM_MANAGED, - "requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"), - }) - .to_string(); - let (url, _auths) = spawn_mock(body); - write_config(&home, &url); - write_team_auth(&home, "team-a"); - xai_grok_shell::managed_config::sync() - .await - .expect("team-a sync should succeed"); - assert!(home.join("requirements.toml").exists()); - assert!( - xai_grok_shell::managed_config::managed_policy_gate().is_ok(), - "team A's intact fail_closed policy must start" - ); - - // Switch to team B while OFFLINE: the 5xx server means the session-start refresh cannot - // purge via the apply path; the gate's own purge must handle the switch. - let (err_url, _c, _a) = spawn_mock_seq(vec![(500, "{}".to_string())]); - write_config(&home, &err_url); - write_team_auth(&home, "team-b"); - let auth_manager = std::sync::Arc::new(xai_grok_shell::auth::AuthManager::new( - &home, - xai_grok_shell::auth::GrokComConfig::default(), - )); - xai_grok_shell::managed_config::ensure_managed_policy_present(&auth_manager).await; - - assert!( - xai_grok_shell::managed_config::managed_policy_gate().is_ok(), - "a legitimate offline team switch must not fail closed" - ); - for f in xai_grok_shell::managed_config::MANAGED_ARTIFACT_FILES - .into_iter() - .chain([xai_grok_config::MANAGED_CONFIG_CACHE_FILE]) - { - assert!( - !home.join(f).exists(), - "team A's {f} must be purged on the switch" - ); - } -} - -/// The gate purge takes the managed-config lock best-effort and SKIPS on contention (the holder -/// owns the transition). While held, an A→B switch retains team A's files — and the gate still -/// permits (a pure identity mismatch is not gate-grade tamper); once released, the next gate -/// call purges — proving the skip was contention-driven, not a silent no-op. -#[tokio::test] -#[serial] -async fn gate_purge_skips_while_lock_contended() { - let home = test_home().clone(); - reset(&home); - - let body = serde_json::json!({ - "deployment_id": serde_json::Value::Null, - "team_id": "team-a", - "managed_config": TEAM_MANAGED, - "requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"), - }) - .to_string(); - let (url, _auths) = spawn_mock(body); - write_config(&home, &url); - write_team_auth(&home, "team-a"); - xai_grok_shell::managed_config::sync() - .await - .expect("team A sync should succeed"); - assert!(home.join("requirements.toml").exists()); - assert!(home.join("managed_config_cache.json").exists()); - - write_team_auth(&home, "team-b"); - - // Hold the managed-config flock (the same lock the gate purge tries), so the purge skips. - let lock = std::fs::OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(home.join("managed_config.lock")) - .unwrap(); - lock.lock().unwrap(); - - assert!( - xai_grok_shell::managed_config::managed_policy_gate().is_ok(), - "a contended purge skip leaves a pure identity mismatch, which must not refuse" - ); - assert!( - home.join("requirements.toml").exists(), - "team A requirements must be RETAINED while the lock is contended (purge skipped)" - ); - assert!( - home.join("managed_config_cache.json").exists(), - "team A marker must be RETAINED while the lock is contended" - ); - - // Release the lock: the next gate call acquires it and purges team A. - lock.unlock().unwrap(); - assert!( - xai_grok_shell::managed_config::managed_policy_gate().is_ok(), - "after the lock releases, the gate purges team A and team B starts" - ); - assert!( - !home.join("requirements.toml").exists(), - "the uncontended gate purges team A's requirements" - ); - assert!( - !home.join("managed_config_cache.json").exists(), - "the uncontended gate purges team A's marker" - ); -} - -/// A TRANSIENT lock holder must not turn an offline team switch into a skipped purge: -/// the purge retries the lock once after 100ms (`PURGE_LOCK_RETRY_DELAY`), so a holder -/// that releases within that window (~20ms here) is absorbed and the SAME gate call -/// purges team A on the second attempt. -#[tokio::test] -#[serial] -async fn gate_purge_retries_past_a_transient_lock_holder() { - let home = test_home().clone(); - reset(&home); - - let body = serde_json::json!({ - "deployment_id": serde_json::Value::Null, - "team_id": "team-a", - "managed_config": TEAM_MANAGED, - "requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"), - }) - .to_string(); - let (url, _auths) = spawn_mock(body); - write_config(&home, &url); - write_team_auth(&home, "team-a"); - xai_grok_shell::managed_config::sync() - .await - .expect("team A sync should succeed"); - assert!(home.join("requirements.toml").exists()); - - write_team_auth(&home, "team-b"); - - // Acquire the flock BEFORE the gate call, then hand it to a helper that releases - // it ~20ms in — inside the purge's retry window. - let lock = std::fs::OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(home.join("managed_config.lock")) - .unwrap(); - lock.lock().unwrap(); - let holder = std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(20)); - drop(lock); // releases the flock - }); - - assert!( - xai_grok_shell::managed_config::managed_policy_gate().is_ok(), - "a pure identity mismatch never refuses, purged or not" - ); - holder.join().unwrap(); - - assert!( - !home.join("requirements.toml").exists(), - "one gate call must absorb the transient holder via the retry and purge team A" - ); - assert!( - !home - .join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE) - .exists(), - "team A's marker goes with the retried purge" - ); -} - -/// A blank `team_id` in `auth.json` (a parse blip / malformed write) is "unknown", not a -/// distinct identity: the gate must NOT fail closed and the purge must NOT shed team A's -/// policy. Guards the blank→None map in `active_team_id_any_expiry` and the detector's -/// blank guard end to end. -#[tokio::test] -#[serial] -async fn blank_team_id_neither_fails_closed_nor_purges() { - let home = test_home().clone(); - reset(&home); - - let body = serde_json::json!({ - "deployment_id": serde_json::Value::Null, - "team_id": "team-a", - "managed_config": TEAM_MANAGED, - "requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"), - }) - .to_string(); - let (url, _auths) = spawn_mock(body); - write_config(&home, &url); - write_team_auth(&home, "team-a"); - xai_grok_shell::managed_config::sync() - .await - .expect("team A sync should succeed"); - assert!(home.join("requirements.toml").exists()); - - // auth.json now carries a team principal with a BLANK team_id. - write_team_auth(&home, ""); - - assert!( - xai_grok_shell::managed_config::managed_policy_gate().is_ok(), - "a blank team_id must read as unknown, not a foreign substituted cache" - ); - assert!( - home.join("requirements.toml").exists(), - "a parse blip must not purge team A's enforced policy" - ); - assert!( - home.join("managed_config_cache.json").exists(), - "the team A marker must be retained on a blank team_id" - ); - assert!( - matches!( - xai_grok_shell::managed_config::current_serving_identity(), - ServingIdentity::None - ), - "a blank team_id must resolve to no identity, not Team(\"\") (spurious refetch input)" - ); -} - -/// The session-start gate reads no env: `GROK_MANAGED_CONFIG_FAIL_CLOSED=0` must NOT disarm a fail_closed -/// refusal (unlike the requirements-layer version check, which the env can only tighten). No local bypass. -#[tokio::test] -#[serial] -async fn fail_closed_env_cannot_disarm_the_gate() { - let home = test_home().clone(); - reset(&home); - - let body = serde_json::json!({ - "deployment_id": "deploy-A", - "managed_config": TEAM_MANAGED, - "requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"), - }) - .to_string(); - let (url, _auths) = spawn_mock(body); - std::fs::write( - home.join("config.toml"), - format!("[endpoints]\nmanaged_config_url = \"{url}\"\ndeployment_key = \"KEY-AAA\"\n"), - ) - .unwrap(); - xai_grok_shell::managed_config::sync() - .await - .expect("deploy-key sync should succeed"); - - // Tamper: delete the served requirements (offline this fails closed). - std::fs::remove_file(home.join("requirements.toml")).unwrap(); - - // SAFETY: #[serial] test; both vars restored before the assertion below. - unsafe { - std::env::set_var("GROK_MANAGED_CONFIG", "0"); // offline (the gate is sync anyway) - std::env::set_var("GROK_MANAGED_CONFIG_FAIL_CLOSED", "0"); // attempt a local disarm - } - let gate = xai_grok_shell::managed_config::managed_policy_gate(); - unsafe { - std::env::remove_var("GROK_MANAGED_CONFIG"); - std::env::remove_var("GROK_MANAGED_CONFIG_FAIL_CLOSED"); - } - - assert!( - gate.is_err(), - "GROK_MANAGED_CONFIG_FAIL_CLOSED=0 must not disarm the session-start gate (no local bypass)" - ); -} - -/// Full logout removes the team scope from `auth.json`; the post-logout clear -/// (what `perform_logout` runs) removes the orphaned team-sourced files. -#[tokio::test] -#[serial] -async fn logout_clears_team_config() { - let home = test_home().clone(); - reset(&home); - - let (url, _last_auth) = spawn_mock(team_config_body()); - write_config(&home, &url); - write_team_auth(&home, "team-007"); - xai_grok_shell::managed_config::sync() - .await - .expect("sync should succeed"); - assert!(home.join("managed_config.toml").exists()); - assert!( - home.join("managed_config_cache.json").exists(), - "a successful sync writes the sync-marker cache" - ); - - // `AuthManager::clear` deletes auth.json when the last scope is removed. - std::fs::remove_file(home.join("auth.json")).unwrap(); - xai_grok_shell::managed_config::clear_orphan(); - - assert!( - !home.join("managed_config.toml").exists(), - "team-sourced managed_config should be cleared on logout" - ); - assert!( - !home.join("requirements.toml").exists(), - "enforced requirements should be cleared on logout" - ); - assert!( - !home.join("managed_config_cache.json").exists(), - "the sync-marker cache should be cleared on logout too" - ); -} - -/// Seed on-disk fail_closed policy for clear_orphan keep tests. -/// `with_managed_files` writes managed_config + sig sidecars. -/// `with_marker` stamps a fail_closed sync marker for team-ms-fail-closed. -fn seed_fail_closed_orphan_artifacts( - home: &std::path::Path, - with_managed_files: bool, - with_marker: bool, -) { - if with_managed_files { - std::fs::write(home.join("managed_config.toml"), TEAM_MANAGED).unwrap(); - std::fs::write(home.join("managed_config.sig.json"), r#"{"key_id":"v1"}"#).unwrap(); - std::fs::write(home.join("managed_identity.sig.json"), r#"{"key_id":"v1"}"#).unwrap(); - } - std::fs::write( - home.join("requirements.toml"), - format!("fail_closed = true\n{TEAM_REQUIREMENTS}"), - ) - .unwrap(); - if with_marker { - xai_grok_shell::config::mark_managed_config_synced(xai_grok_shell::config::SyncMarker { - principal: Some("team-ms-fail-closed"), - had_managed_config: with_managed_files, - had_requirements: true, - key_fingerprint: None, - fail_closed: true, - }); - } -} - -/// fail_closed escape fix: personal (User) auth with leftover MS fail_closed -/// artifacts must NOT be wiped by `clear_orphan` — that was the offline -/// switch-to-personal escape (managed files deleted, session ALLOW unrestricted). -#[test] -#[serial] -fn clear_orphan_keeps_fail_closed_when_switched_to_personal() { - let home = test_home().clone(); - reset(&home); - seed_fail_closed_orphan_artifacts(&home, true, true); - - // Personal User principal (no team_id) — the escape repro. - let scope = xai_grok_shell::auth::GrokComConfig::default().auth_scope(); - let auth = serde_json::json!({ - scope: { - "key": "personal-token", - "auth_mode": "oidc", - "create_time": "2026-01-01T00:00:00Z", - "expires_at": FAR_FUTURE, - "user_id": "user-1", - } - }); - std::fs::write(home.join("auth.json"), auth.to_string()).unwrap(); - - xai_grok_shell::managed_config::clear_orphan(); - - assert!( - home.join("requirements.toml").exists(), - "fail_closed requirements must survive personal identity switch" - ); - assert!( - home.join("managed_config.toml").exists(), - "fail_closed managed_config must survive personal identity switch" - ); - assert!( - home.join("managed_config.sig.json").exists(), - "sig sidecar must survive personal identity switch under fail_closed" - ); - assert!( - home.join("managed_config_cache.json").exists(), - "fail_closed marker must survive personal identity switch" - ); -} - -/// Signed-out logout with fail_closed still keeps policy (same as personal switch). -#[test] -#[serial] -fn clear_orphan_keeps_fail_closed_when_signed_out() { - let home = test_home().clone(); - reset(&home); - seed_fail_closed_orphan_artifacts(&home, false, true); - // No auth.json = signed out. - xai_grok_shell::managed_config::clear_orphan(); - - assert!( - home.join("requirements.toml").exists(), - "signed-out must not wipe fail_closed requirements" - ); - assert!( - home.join("managed_config_cache.json").exists(), - "signed-out must not wipe fail_closed marker" - ); -} - -/// Marker stripped but requirements still say fail_closed = true: still keep. -#[test] -#[serial] -fn clear_orphan_keeps_fail_closed_requirements_without_marker() { - let home = test_home().clone(); - reset(&home); - seed_fail_closed_orphan_artifacts(&home, false, false); - // No marker, no team auth. - xai_grok_shell::managed_config::clear_orphan(); - - assert!( - home.join("requirements.toml").exists(), - "on-disk fail_closed requirements must be kept even without a marker" - ); -} - -/// Unreadable requirements (PermissionDenied) with no fail_closed marker must -/// still keep artifacts — cannot confirm disarmed, so clear_orphan must not wipe. -#[test] -#[serial] -#[cfg(unix)] -fn clear_orphan_keeps_unreadable_requirements_without_marker() { - use std::os::unix::fs::PermissionsExt; - - let home = test_home().clone(); - reset(&home); - seed_fail_closed_orphan_artifacts(&home, true, false); - // No fail_closed marker; requirements exist with fail_closed = true but will - // be made unreadable so the flag cannot be parsed. - let req = home.join("requirements.toml"); - std::fs::set_permissions(&req, std::fs::Permissions::from_mode(0o000)).unwrap(); - struct RestorePerms<'a>(&'a std::path::Path); - impl Drop for RestorePerms<'_> { - fn drop(&mut self) { - let _ = std::fs::set_permissions(self.0, std::fs::Permissions::from_mode(0o600)); - } - } - let _restore = RestorePerms(&req); - - assert!( - xai_grok_config::fail_closed_policy_armed_at(&home), - "unreadable requirements must arm fail_closed" - ); - xai_grok_shell::managed_config::clear_orphan(); - - // Restore so exists() / cleanup can inspect the tree. - drop(_restore); - assert!( - home.join("requirements.toml").exists(), - "unreadable requirements must not be wiped by clear_orphan" - ); - assert!( - home.join("managed_config.toml").exists(), - "managed_config must survive when requirements are unreadable" - ); -} - -/// An expired token for a still-signed-in team is not a logout: cold-start -/// tokens are routinely expired before refresh, so the clear is expiry-agnostic. -#[test] -#[serial] -fn cold_start_expired_token_keeps_config() { - let home = test_home().clone(); - reset(&home); - - std::fs::write(home.join("managed_config.toml"), TEAM_MANAGED).unwrap(); - std::fs::write(home.join("requirements.toml"), TEAM_REQUIREMENTS).unwrap(); - write_team_auth_expiry(&home, "team-007", PAST); - xai_grok_shell::managed_config::clear_orphan(); - - assert!( - home.join("managed_config.toml").exists(), - "expired-but-present team token must not wipe enforced managed_config" - ); - assert!( - home.join("requirements.toml").exists(), - "expired-but-present team token must not wipe enforced requirements" - ); -} - -/// Fail-closed: an UNREADABLE (corrupt) auth.json is not a logout — the clear -/// must keep the team's enforced files until the read recovers. -#[test] -#[serial] -fn unreadable_auth_keeps_config() { - let home = test_home().clone(); - reset(&home); - - std::fs::write(home.join("requirements.toml"), TEAM_REQUIREMENTS).unwrap(); - std::fs::write(home.join("auth.json"), "{corrupt json").unwrap(); - xai_grok_shell::managed_config::clear_orphan(); - - assert!( - home.join("requirements.toml").exists(), - "an unreadable auth.json must not wipe enforced policy" - ); -} - -#[tokio::test] -#[serial] -async fn deployment_key_wins_over_team_when_both_present() { - let home = test_home().clone(); - reset(&home); - - let (url, auths) = spawn_mock(team_config_body()); - std::fs::write( - home.join("config.toml"), - format!("[endpoints]\nmanaged_config_url = \"{url}\"\ndeployment_key = \"dep-key-123\"\n"), - ) - .unwrap(); - write_team_auth(&home, "team-007"); - - let wrote = xai_grok_shell::managed_config::sync() - .await - .expect("sync should succeed"); - assert!(wrote); - assert_eq!( - auths.lock().unwrap().last().map(String::as_str), - Some("Bearer dep-key-123"), - "deployment key must win over the team token" - ); -} - -/// A successful deploy-key sync records the served `deployment_id` as `principal` and a non-empty -/// `key_fingerprint` (one-way hash), never the raw key — so a switched key stops serving the prior config. -#[tokio::test] -#[serial] -async fn deployment_key_sync_records_principal_and_key_fingerprint() { - let home = test_home().clone(); - reset(&home); - - let body = serde_json::json!({ - "deployment_id": "dep-42", - "managed_config": "[cli]\ntheme = \"dark\"\n", - "requirements": "[features]\nweb_fetch = false\n", - }) - .to_string(); - let (url, _auths) = spawn_mock(body); - std::fs::write( - home.join("config.toml"), - format!( - "[endpoints]\nmanaged_config_url = \"{url}\"\ndeployment_key = \"dep-key-secret\"\n" - ), - ) - .unwrap(); - - let wrote = xai_grok_shell::managed_config::sync() - .await - .expect("deployment-key sync should succeed"); - assert!(wrote); - - let marker = std::fs::read_to_string(home.join("managed_config_cache.json")).unwrap(); - let v: serde_json::Value = serde_json::from_str(&marker).unwrap(); - assert_eq!( - v["principal"].as_str(), - Some("dep-42"), - "deploy-key marker records the served deployment_id as principal: {marker}" - ); - let fp = v["key_fingerprint"] - .as_str() - .expect("deploy-key marker records a key fingerprint"); - assert!(!fp.is_empty(), "the key fingerprint must be non-empty"); - assert!( - !marker.contains("dep-key-secret"), - "the raw deployment key must never be written to disk: {marker}" - ); -} - -/// A configured deployment key keeps its files even with no team signed in — -/// the orphan clear must never delete a deployment-key install's config. -#[test] -#[serial] -fn deployment_key_config_survives_clear() { - let home = test_home().clone(); - reset(&home); - - // A deployment-key install: files present + the key persisted in config.toml. - std::fs::write( - home.join("config.toml"), - "[endpoints]\ndeployment_key = \"dep-key-123\"\n", - ) - .unwrap(); - std::fs::write( - home.join("managed_config.toml"), - "[cli]\ninstaller = \"internal\"\n", - ) - .unwrap(); - let _ = std::fs::remove_file(home.join("auth.json")); - - xai_grok_shell::managed_config::clear_orphan(); - - assert!( - home.join("managed_config.toml").exists(), - "deployment-key managed_config must survive the orphan clear" - ); -} - -/// A personal (non-team) OAuth login is not eligible: no bearer sent, nothing -/// written. Guards the `is_team_principal()` eligibility check. -#[tokio::test] -#[serial] -async fn personal_login_is_noop() { - let home = test_home().clone(); - reset(&home); - - let (url, count, _) = spawn_mock_seq(vec![(200, team_config_body())]); - write_config(&home, &url); - // A signed-in USER principal (no team_id, principal_type absent). - let scope = xai_grok_shell::auth::GrokComConfig::default().auth_scope(); - let auth = serde_json::json!({ - scope: { - "key": "personal-token", - "auth_mode": "oidc", - "create_time": "2026-01-01T00:00:00Z", - "expires_at": FAR_FUTURE, - "user_id": "user-1", - } - }); - std::fs::write(home.join("auth.json"), auth.to_string()).unwrap(); - - let wrote = xai_grok_shell::managed_config::sync() - .await - .expect("personal login → no-op, not an error"); - assert!(!wrote, "a personal login must not fetch team config"); - assert_eq!(*count.lock().unwrap(), 0, "no bearer sent to the endpoint"); - assert!(!home.join("managed_config.toml").exists()); -} - -/// Security guard: a lock-skipped apply (dk row HAS config) must not be read as -/// an empty row and fall through to the team token on a deployment-key machine. -#[tokio::test] -#[serial] -async fn lock_contention_does_not_fall_through_to_team() { - let home = test_home().clone(); - reset(&home); - - // dk row returns real config; a team principal is also present. - let (url, count, auths) = spawn_mock_seq(vec![(200, team_config_body())]); - std::fs::write( - home.join("config.toml"), - format!("[endpoints]\nmanaged_config_url = \"{url}\"\ndeployment_key = \"dep-key\"\n"), - ) - .unwrap(); - write_team_auth(&home, "team-007"); - - // Hold the managed-config lock (same flock the client uses) so apply_fetched - // skips and returns Ok(false). - let lock = std::fs::OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(home.join("managed_config.lock")) - .unwrap(); - lock.lock().unwrap(); - - let wrote = xai_grok_shell::managed_config::sync() - .await - .expect("contended sync is a no-op, not an error"); - lock.unlock().unwrap(); - - assert!(!wrote, "nothing applied while the lock is held"); - // The dk fetch happened; the team token must NOT have been tried as a - // fallthrough (that would fetch the team's config onto a dk machine). - assert_eq!( - auths.lock().unwrap().as_slice(), - ["Bearer dep-key"], - "contention must not trigger the dk->team fallthrough" - ); - assert_eq!(*count.lock().unwrap(), 1); -} - -/// `grok setup` with config served but the lock held by another writer reports the -/// skip: not Installed (THIS run persisted nothing) and not NothingConfigured (the -/// server does have config). -#[tokio::test] -#[serial] -async fn setup_lock_skip_is_not_reported_as_no_config() { - let home = test_home().clone(); - reset(&home); - - let (url, _auths) = spawn_mock(team_config_body()); - write_config(&home, &url); - write_team_auth(&home, "team-007"); - - let lock = std::fs::OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(home.join("managed_config.lock")) - .unwrap(); - lock.lock().unwrap(); - - let outcome = xai_grok_shell::managed_config::run_setup().await; - lock.unlock().unwrap(); - - assert!( - matches!( - outcome, - xai_grok_shell::managed_config::SetupOutcome::Skipped - ), - "a lock skip persisted nothing: it must report Skipped, not Installed or \ - NothingConfigured, got {outcome:?}" - ); -} - -/// A transient (5xx) failure is retried; once the server recovers, the config -/// is written. (Backoff is overridden to 10ms in `test_home`.) -#[tokio::test] -#[serial] -async fn sync_retries_after_transient_error() { - let home = test_home().clone(); - reset(&home); - - let (url, count, _auths) = - spawn_mock_seq(vec![(500, "boom".into()), (200, team_config_body())]); - write_config(&home, &url); - write_team_auth(&home, "team-007"); - - let wrote = xai_grok_shell::managed_config::sync() - .await - .expect("should retry the 500 then succeed"); - assert!(wrote); - assert!(home.join("managed_config.toml").exists()); - assert!( - *count.lock().unwrap() >= 2, - "expected a retry after the transient 500" - ); -} - -/// A body-phase interruption — the server writes valid headers then drops before the -/// body completes ("connection closed before message completed", a `reqwest` body error, -/// NOT a decode error) — must be classified transient and recovered on a fresh -/// connection. Pre-fix this mapped to non-retryable `InvalidResponse` and would NOT -/// retry; here `sync()` succeeds. (Backoff is 10ms via `test_home`.) -#[tokio::test] -#[serial] -async fn sync_retries_after_body_phase_drop() { - let home = test_home().clone(); - reset(&home); - - let (url, count) = spawn_mock_truncating_body_first(1, team_config_body()); - write_config(&home, &url); - write_team_auth(&home, "team-007"); - - let wrote = xai_grok_shell::managed_config::sync() - .await - .expect("a mid-body drop must be retried on a fresh connection, then succeed"); - assert!(wrote); - assert!(home.join("managed_config.toml").exists()); - assert!( - *count.lock().unwrap() >= 2, - "expected a retry on a new connection after the first body read was interrupted" - ); -} - -/// Classification: an in-flight connection interruption must NOT be misreported as -/// an unreachable server — the error must not blame the user's network (the bug), -/// and must surface the transient-interruption wording instead. -#[tokio::test] -#[serial] -async fn connection_drop_is_not_reported_as_unreachable() { - let home = test_home().clone(); - reset(&home); - - // Every connection is dropped mid-flight, so all retries fail identically. - let (url, _count) = spawn_mock_closing_first(usize::MAX, team_config_body()); - write_config(&home, &url); - write_team_auth(&home, "team-007"); - - let err = xai_grok_shell::managed_config::sync() - .await - .expect_err("all connections dropped → the fetch fails"); - let msg = err.to_string().to_lowercase(); - assert!( - !msg.contains("check your network"), - "an in-flight interruption must not be misreported as unreachable: {msg}" - ); - assert!( - msg.contains("interrupted") || msg.contains("timed out"), - "the message must describe a transient connection interruption: {msg}" - ); -} - -/// The payload side of the body split: a 200 with a non-JSON body is a malformed payload, not a -/// transport interruption — it must fail TERMINALLY (`InvalidResponse`), not retry. Guards the -/// `from_slice` arm of the split; the transport arm is covered by `sync_retries_after_body_phase_drop`. -#[tokio::test] -#[serial] -async fn sync_fails_terminally_on_malformed_payload() { - let home = test_home().clone(); - reset(&home); - - // A 200 whose body is not JSON (e.g. an HTML error page slipped through a proxy). - let (url, count, _auths) = spawn_mock_seq(vec![(200, "not json".into())]); - write_config(&home, &url); - write_team_auth(&home, "team-007"); - - let err = xai_grok_shell::managed_config::sync() - .await - .expect_err("a malformed payload must fail, not write config"); - assert_eq!( - *count.lock().unwrap(), - 1, - "a malformed payload is terminal and must not be retried" - ); - let msg = err.to_string().to_lowercase(); - assert!( - msg.contains("unexpected response"), - "a malformed payload must surface as an unexpected-response error, not a transient one: {msg}" - ); - assert!(!home.join("managed_config.toml").exists()); -} - -// note: this asserts the POOLING half of the `shared_client()` tuning. Evicting/recovering a -// half-dead h2 connection (and the `send_with_retry_escaping_pool` fresh-final-attempt escape) isn't -// deterministically simulable with the std `TcpListener` harness, so it's covered by the e2e pass. - -/// The tuned pooled `shared_client()` reuses one TCP connection across back-to-back requests (pool -/// eviction is time-based, so two quick requests share a connection). Regression guard against a -/// future mis-tuning that disables pooling for the general-purpose client. -#[tokio::test] -#[serial] -async fn shared_client_reuses_pooled_connection() { - // Scrub the suite's env (notably HTTP(S)_PROXY) before `shared_client()` is built, so a proxy - // can't intercept the 127.0.0.1 mock and break the accept count regardless of test ordering. - let _ = test_home(); - let (base_url, accepts, _heads) = spawn_counting_server().await; - let client = xai_grok_shell::http::shared_client(); - - client - .get(base_url.as_str()) - .send() - .await - .expect("first request succeeds") - .bytes() - .await - .expect("first body reads to completion"); - // Brief pause so the idle connection is checked back into the pool before the second request. - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - client - .get(base_url.as_str()) - .send() - .await - .expect("second request succeeds") - .bytes() - .await - .expect("second body reads to completion"); - - assert_eq!( - accepts.load(std::sync::atomic::Ordering::SeqCst), - 1, - "the pooled client must reuse one TCP connection across back-to-back requests" - ); -} - -/// An auth rejection is terminal: fail fast with the team-tailored message and -/// no retries (a bad credential won't fix itself by retrying). -#[tokio::test] -#[serial] -async fn sync_fails_fast_on_auth_error_without_retry() { - let home = test_home().clone(); - reset(&home); - - let (url, count, _auths) = spawn_mock_seq(vec![(401, "{\"error\":\"nope\"}".into())]); - write_config(&home, &url); - write_team_auth(&home, "team-007"); - - let err = xai_grok_shell::managed_config::sync() - .await - .expect_err("401 should be an error"); - assert_eq!(*count.lock().unwrap(), 1, "auth error must not be retried"); - let msg = err.to_string().to_lowercase(); - assert!(msg.contains("team sign-in"), "team-tailored message: {msg}"); -} - -/// A rejected deployment key (stale env/config leftover) must not starve a -/// valid team sign-in: the sync falls back to the team session token. -#[tokio::test] -#[serial] -async fn rejected_deployment_key_falls_back_to_team() { - let home = test_home().clone(); - reset(&home); - - let (url, count, auths) = spawn_mock_seq(vec![ - (401, "{\"error\":\"bad key\"}".into()), // deployment key attempt - (200, team_config_body()), // team fallback - ]); - std::fs::write( - home.join("config.toml"), - format!("[endpoints]\nmanaged_config_url = \"{url}\"\ndeployment_key = \"stale-key\"\n"), - ) - .unwrap(); - write_team_auth(&home, "team-007"); - - let wrote = xai_grok_shell::managed_config::sync() - .await - .expect("team fallback should succeed after the key is rejected"); - - assert!(wrote); - assert_eq!(*count.lock().unwrap(), 2, "key attempt + team attempt"); - assert_eq!( - auths.lock().unwrap().as_slice(), - ["Bearer stale-key", "Bearer team-session-token"], - "first the rejected key, then the team token" - ); -} - -/// A deployment key whose response is EMPTY (no row provisioned) must not -/// starve the signed-in team: the sync falls through to the team token. -#[tokio::test] -#[serial] -async fn empty_deployment_response_falls_through_to_team() { - let home = test_home().clone(); - reset(&home); - - let (url, count, auths) = spawn_mock_seq(vec![ - (200, "{}".into()), // deployment key: no row - (200, team_config_body()), // team fallback - ]); - std::fs::write( - home.join("config.toml"), - format!("[endpoints]\nmanaged_config_url = \"{url}\"\ndeployment_key = \"dep-key\"\n"), - ) - .unwrap(); - write_team_auth(&home, "team-007"); - - let wrote = xai_grok_shell::managed_config::sync() - .await - .expect("team fallthrough should succeed"); - - assert!(wrote); - assert_eq!(*count.lock().unwrap(), 2); - assert_eq!( - auths.lock().unwrap().as_slice(), - ["Bearer dep-key", "Bearer team-session-token"] - ); -} - -/// A deployment row with empty content (echoed `deployment_id`) still owns the -/// machine: fallthrough gates on existence, not content, so the team token isn't -/// tried. -#[tokio::test] -#[serial] -async fn empty_content_deployment_row_does_not_fall_through_to_team() { - let home = test_home().clone(); - reset(&home); - - // 200 with a row (deployment_id present) but empty content; team config is - // queued second so a wrong fallthrough would be observable. - let degraded = serde_json::json!({ - "deployment_id": "dep-1", - "managed_config": "", - "requirements": serde_json::Value::Null, - }) - .to_string(); - let (url, count, auths) = spawn_mock_seq(vec![(200, degraded), (200, team_config_body())]); - std::fs::write( - home.join("config.toml"), - format!("[endpoints]\nmanaged_config_url = \"{url}\"\ndeployment_key = \"dep-key\"\n"), - ) - .unwrap(); - write_team_auth(&home, "team-007"); - - let wrote = xai_grok_shell::managed_config::sync() - .await - .expect("degraded dk row is a no-op, not an error"); - - assert!(!wrote, "empty content writes nothing"); - assert_eq!( - *count.lock().unwrap(), - 1, - "a real dk row must not trigger the team fetch" - ); - assert_eq!( - auths.lock().unwrap().as_slice(), - ["Bearer dep-key"], - "a provisioned-but-empty dk row must not fall through to the team token" - ); - assert!(!home.join("managed_config.toml").exists()); -} - -/// `GROK_MANAGED_CONFIG=0` is an explicit opt-out: the post-login sync must -/// make zero requests. -#[tokio::test] -#[serial] -async fn managed_config_opt_out_makes_no_requests() { - let home = test_home().clone(); - reset(&home); - - let (url, count, _) = spawn_mock_seq(vec![(200, team_config_body())]); - write_config(&home, &url); - write_team_auth(&home, "team-007"); - - // SAFETY: #[serial] test; restored before returning. - unsafe { std::env::set_var("GROK_MANAGED_CONFIG", "0") }; - let outcome = xai_grok_shell::managed_config::post_login_sync(None).await; - unsafe { std::env::remove_var("GROK_MANAGED_CONFIG") }; - - assert_eq!( - outcome, - xai_grok_shell::managed_config::ManagedConfigSync::Skipped - ); - - assert_eq!(*count.lock().unwrap(), 0, "opt-out must suppress the fetch"); - assert!(!home.join("managed_config.toml").exists()); -} - -/// Post-login pins the just-authenticated principal over a different on-disk -/// team — a login to team B can't sync team A's policy. -#[tokio::test] -#[serial] -async fn post_login_pins_authenticated_team_over_disk() { - let home = test_home().clone(); - reset(&home); - - let (url, auths) = spawn_mock(team_config_body()); - write_config(&home, &url); - // The on-disk "current" team uses the default "team-session-token". - write_team_auth(&home, "team-disk"); - - // But we just authenticated as a different team with a distinct token. - let pinned: xai_grok_shell::auth::GrokAuth = serde_json::from_value(serde_json::json!({ - "key": "pinned-token", - "auth_mode": "oidc", - "create_time": "2026-01-01T00:00:00Z", - "expires_at": FAR_FUTURE, - "user_id": "user-1", - "principal_type": "Team", - "team_id": "team-pinned", - })) - .unwrap(); - - let outcome = xai_grok_shell::managed_config::post_login_sync(Some(pinned)).await; - assert_eq!( - outcome, - xai_grok_shell::managed_config::ManagedConfigSync::Updated { is_team: true } - ); - assert_eq!( - auths.lock().unwrap().last().map(String::as_str), - Some("Bearer pinned-token"), - "must authenticate as the pinned principal, not the on-disk current team" - ); -} - -/// The login-path sync stops after its small retry budget (2), not the full -/// background budget (5), and never surfaces an error. -#[tokio::test] -#[serial] -async fn post_login_sync_is_latency_bounded() { - let home = test_home().clone(); - reset(&home); - - let (url, count, _auths) = spawn_mock_seq(vec![(500, "boom".into())]); - write_config(&home, &url); - write_team_auth(&home, "team-007"); - - let outcome = xai_grok_shell::managed_config::post_login_sync(None).await; - - assert_eq!( - outcome, - xai_grok_shell::managed_config::ManagedConfigSync::Failed - ); - assert_eq!( - *count.lock().unwrap(), - 2, - "login sync must stop after its bounded retry budget" - ); - assert!(!home.join("managed_config.toml").exists()); -} - -/// A deploy key is local config any process can write, not a signed-in identity — so on a dk -/// machine the gate purge must never fire, even when `auth.json` shows a confirmed team switch -/// underneath: purging would let any local process shed the key's policy offline. -#[tokio::test] -#[serial] -async fn deploy_key_machine_never_gate_purges_on_team_switch() { - let home = test_home().clone(); - reset(&home); - - // Sync as team A, then go offline and switch auth.json to team B — the purge-eligible shape. - let body = serde_json::json!({ - "deployment_id": serde_json::Value::Null, - "team_id": "team-a", - "managed_config": TEAM_MANAGED, - "requirements": TEAM_REQUIREMENTS, - }) - .to_string(); - let (url, _auths) = spawn_mock(body); - write_config(&home, &url); - write_team_auth(&home, "team-a"); - xai_grok_shell::managed_config::sync() - .await - .expect("team-a sync should succeed"); - assert!(home.join("managed_config.toml").exists()); - - // Same switch as the purging sibling test, but with a deployment key configured: the - // serving identity resolves to the key, so the Team-only purge path must not run. - let (err_url, _c, _a) = spawn_mock_seq(vec![(500, "{}".to_string())]); - std::fs::write( - home.join("config.toml"), - format!( - "[endpoints]\nmanaged_config_url = \"{err_url}\"\ndeployment_key = \"dk-under-test\"\n" - ), - ) - .unwrap(); - write_team_auth(&home, "team-b"); - let auth_manager = std::sync::Arc::new(xai_grok_shell::auth::AuthManager::new( - &home, - xai_grok_shell::auth::GrokComConfig::default(), - )); - xai_grok_shell::managed_config::ensure_managed_policy_present(&auth_manager).await; - - // The gate is the purge's only caller — without this call the guard is unexercised. - assert!( - xai_grok_shell::managed_config::managed_policy_gate().is_ok(), - "dk gate must permit" - ); - assert!( - home.join("managed_config.toml").exists(), - "a dk machine must keep its policy across an auth.json team flip" - ); - assert!( - home.join("managed_config_cache.json").exists(), - "the sync marker must survive too — the key, not the team, owns this machine's policy" - ); -} - -/// For every on-disk state a crashed purge can leave (each proper prefix of the removal -/// order), the marker is still present, the detector still fires for the new team, and a -/// later purge converges. The order itself is pinned by `marker_is_not_a_managed_artifact` -/// plus the fault-injection unit test. -#[tokio::test] -#[serial] -async fn purge_crash_prefixes_stay_armed_and_converge() { - let home = test_home().clone(); - let artifacts = xai_grok_shell::managed_config::MANAGED_ARTIFACT_FILES; - // 0..=len: every proper prefix of the 4-step removal order, up to and including - // "all artifacts removed, marker still present" (a crash right before the marker step). - for prefix_len in 0..=artifacts.len() { - reset(&home); - let body = serde_json::json!({ - "deployment_id": serde_json::Value::Null, - "team_id": "team-a", - "managed_config": TEAM_MANAGED, - "requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"), - }) - .to_string(); - let (url, _auths) = spawn_mock(body); - write_config(&home, &url); - write_team_auth(&home, "team-a"); - xai_grok_shell::managed_config::sync() - .await - .expect("team-a sync should succeed"); - - // Simulate a purge crashed after removing only this prefix. - for name in &artifacts[..prefix_len] { - let _ = std::fs::remove_file(home.join(name)); - } - assert!( - home.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE) - .exists(), - "marker must outlive every artifact prefix (prefix_len={prefix_len})" - ); - - // Team B arrives offline: the detector must still confirm and the purge converge. - write_team_auth(&home, "team-b"); - assert_eq!( - xai_grok_config::confirmed_team_switch("team-b").as_deref(), - Some("team-a"), - "detector must stay armed after a crash prefix (prefix_len={prefix_len})" - ); - assert!( - xai_grok_shell::managed_config::managed_policy_gate().is_ok(), - "offline switch over a crash prefix must not refuse (prefix_len={prefix_len})" - ); - for name in artifacts { - assert!( - !home.join(name).exists(), - "{name} must be purged (prefix_len={prefix_len})" - ); - } - assert!( - !home - .join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE) - .exists(), - "the converged purge drops the marker last (prefix_len={prefix_len})" - ); - } -} - -/// Marker written under the apply lock by the holder only: lock-contended apply records nothing. -#[tokio::test] -#[serial] -async fn contended_sync_writes_no_marker() { - let home = test_home().clone(); - reset(&home); - - let body = serde_json::json!({ - "deployment_id": serde_json::Value::Null, - "team_id": "team-a", - "managed_config": TEAM_MANAGED, - "requirements": TEAM_REQUIREMENTS, - }) - .to_string(); - let (url, auths) = spawn_mock(body); - write_config(&home, &url); - write_team_auth(&home, "team-a"); - - // Hold the managed-config flock across the sync: apply skips, so nothing is persisted. - let lock = std::fs::OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(home.join("managed_config.lock")) - .unwrap(); - lock.lock().unwrap(); - let synced = xai_grok_shell::managed_config::sync() - .await - .expect("sync should succeed (skip, not error)"); - assert!(!synced, "a lock-contended apply must not report a write"); - lock.unlock().unwrap(); - - // Positive control: the FETCH happened (only the apply was skipped), so the - // no-marker assertions below can't pass vacuously on a sync that never ran. - assert!( - !auths.lock().unwrap().is_empty(), - "the fetch must have reached the server" - ); - assert!( - !home - .join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE) - .exists(), - "a contended sync must not write a marker for files it never persisted" - ); - assert!(!home.join("requirements.toml").exists()); -} - -/// Credential vanished mid-fetch → apply Skipped, no marker (sibling of contention skip). -#[tokio::test] -#[serial] -async fn credential_gone_mid_fetch_writes_no_marker() { - let home = test_home().clone(); - reset(&home); - - let body = serde_json::json!({ - "deployment_id": "dep-1", - "managed_config": TEAM_MANAGED, - "requirements": TEAM_REQUIREMENTS, - }) - .to_string(); - // Delay the response so we can clear the deployment key after the fetch starts - // but before apply runs. - let (url, count, auths) = spawn_mock_delayed(body, std::time::Duration::from_millis(200)); - std::fs::write( - home.join("config.toml"), - format!( - "[endpoints]\nmanaged_config_url = \"{url}\"\ndeployment_key = \"KEY-GOING-AWAY\"\n" - ), - ) - .unwrap(); - - let home_for_clear = home.clone(); - let clearer = std::thread::spawn(move || { - // Wait until the mock has accepted a request, then drop the key. - for _ in 0..50 { - if *count.lock().unwrap() > 0 { - break; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } - std::fs::write( - home_for_clear.join("config.toml"), - format!("[endpoints]\nmanaged_config_url = \"{url}\"\n"), - ) - .unwrap(); - }); - - let synced = xai_grok_shell::managed_config::sync() - .await - .expect("sync should succeed (skip, not error)"); - clearer.join().unwrap(); - - assert!(!synced, "credential-gone apply must not report a write"); - assert!( - !auths.lock().unwrap().is_empty(), - "the fetch must have reached the server" - ); - assert!( - !home - .join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE) - .exists(), - "credential-gone must not write a marker for an unapplied body" - ); - assert!(!home.join("requirements.toml").exists()); -} - -/// A dk-synced marker means the KEY owns this machine's policy: with the key line gone -/// from config.toml (the shape of a transient read failure) and a team user signed in, -/// the gate must NOT purge. Pins the marker-scoped exemption — one keyed on live config -/// resolution would purge here. -#[tokio::test] -#[serial] -async fn dk_synced_marker_survives_config_blip_with_team_signed_in() { - let home = test_home().clone(); - reset(&home); - - let body = serde_json::json!({ - "deployment_id": "deploy-A", - "managed_config": TEAM_MANAGED, - "requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"), - }) - .to_string(); - let (url, _auths) = spawn_mock(body); - std::fs::write( - home.join("config.toml"), - format!("[endpoints]\nmanaged_config_url = \"{url}\"\ndeployment_key = \"KEY-AAA\"\n"), - ) - .unwrap(); - xai_grok_shell::managed_config::sync() - .await - .expect("deploy-key sync should succeed"); - assert!(home.join("requirements.toml").exists()); - - // The blip: the key line is gone (same shape as a transient config read failure), - // while a team user is also signed in. Identity resolves Team("team-b"), which - // differs from the marker principal ("deploy-A") — but the marker is key-scoped. - write_config(&home, &url); - write_team_auth(&home, "team-b"); - assert_eq!( - xai_grok_config::confirmed_team_switch("team-b"), - None, - "a key-scoped marker must never confirm a team switch" - ); - assert!( - xai_grok_shell::managed_config::managed_policy_gate().is_ok(), - "the blip must not refuse: the key-scoped marker still matches the on-disk policy" - ); - assert!( - home.join("requirements.toml").exists(), - "the machine's enforced policy must survive the blip" - ); - assert!( - home.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE) - .exists(), - "the dk marker must survive the blip" - ); -} diff --git a/crates/codegen/xai-grok-shell/tests/test_session_end_hook_e2e.rs b/crates/codegen/xai-grok-shell/tests/test_session_end_hook_e2e.rs new file mode 100644 index 0000000..4c3fea9 --- /dev/null +++ b/crates/codegen/xai-grok-shell/tests/test_session_end_hook_e2e.rs @@ -0,0 +1,122 @@ +//! Built-binary e2e: SessionEnd hooks fire on headless process exit. +//! +//! Regression for the non-leader quit path that used to cancel the agent +//! without flushing session actors, so SessionEnd never ran on `/exit` / +//! `grok -p` exit. +//! +//! `#[ignore]`d by default — needs the grok binary (`GROK_BINARY` or a local +//! debug build): +//! ```bash +//! cargo test -p xai-grok-shell --test test_session_end_hook_e2e -- --ignored +//! ``` +//! +//! CI coverage of the same machinery without a built binary lives in +//! `xai_grok_shell::agent::activity` tests (the flush quiesce loop and its +//! grace expiry) and `xai_grok_pager::acp::spawn` tests (the worker join: +//! clean exit, worker error, panic rendering, and the abandon-at-budget +//! branch this e2e cannot reach). + +use xai_grok_test_support::*; + +/// Runs headless with a SessionEnd hook that writes stdin + a marker file. +async fn run_with_session_end_hook() -> (HeadlessResult, MockInferenceServer, tempfile::TempDir) { + let state_dir = tempfile::TempDir::new().expect("create state dir"); + let server = MockInferenceServer::start() + .await + .expect("start mock server"); + let sandbox = TestSandbox::builder().mock_url(server.url()).git().build(); + + let state = state_dir.path().display(); + let script_path = sandbox.home().join("session_end_hook.sh"); + std::fs::write( + &script_path, + format!( + "#!/bin/sh\n\ + cat > {state}/stdin.json\n\ + touch {state}/marker\n\ + exit 0\n" + ), + ) + .expect("write hook script"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)) + .expect("chmod hook script"); + } + + let hooks_dir = sandbox.grok_home().join("hooks"); + std::fs::create_dir_all(&hooks_dir).expect("create hooks dir"); + std::fs::write( + hooks_dir.join("session_end.json"), + serde_json::json!({ + "hooks": { + "SessionEnd": [{ + "hooks": [{ + "type": "command", + "command": format!("sh {}", script_path.display()), + "timeout": 30 + }] + }] + } + }) + .to_string(), + ) + .expect("write hook config"); + + let mut cmd = tokio::process::Command::new(grok_binary()); + cmd.args(["-p", "say hello", "--yolo"]) + .current_dir(sandbox.workspace()) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + let result = run_headless_in_sandbox(cmd, sandbox).await; + + (result, server, state_dir) +} + +#[tokio::test] +#[ignore] +async fn session_end_hook_fires_on_headless_exit() { + let (result, server, state_dir) = run_with_session_end_hook().await; + assert_headless_success(&result, "session_end hook e2e", Some(&server)); + + let marker = state_dir.path().join("marker"); + assert!( + marker.is_file(), + "SessionEnd hook must write a marker on process exit (non-leader flush path); \ + missing {marker:?}. stderr:\n{}", + result.stderr + ); + + let stdin_path = state_dir.path().join("stdin.json"); + let text = std::fs::read_to_string(&stdin_path) + .unwrap_or_else(|e| panic!("read {}: {e}", stdin_path.display())); + let envelope: serde_json::Value = + serde_json::from_str(&text).unwrap_or_else(|e| panic!("hook stdin not JSON: {e}\n{text}")); + + let event = envelope["hookEventName"] + .as_str() + .unwrap_or_else(|| panic!("hookEventName missing: {envelope}")); + assert!( + event == "session_end" || event == "SessionEnd", + "expected SessionEnd event name, got {event:?}" + ); + + // `reason` is an already-shipped part of the hook payload that user scripts + // match on: `shutdown` is emitted by the `SessionCommand::Shutdown` arm + // (leader auto-update / relaunch today), `channel_closed` by the actor's + // channel-closed arm. This change adds no new value — it routes non-leader + // exits through the existing Shutdown command — so renaming `shutdown` to + // something narrower here would break those scripts. Future distinct causes + // (e.g. a signal-driven or idle-eviction end) should be added as new values + // alongside it. + let reason = envelope["reason"] + .as_str() + .unwrap_or_else(|| panic!("reason missing: {envelope}")); + assert_eq!( + reason, "shutdown", + "flush path should send SessionCommand::Shutdown (reason=shutdown), got {reason:?}" + ); +} diff --git a/crates/codegen/xai-grok-tools/src/types/output.rs b/crates/codegen/xai-grok-tools/src/types/output.rs index 8080e5e..fb7fcba 100644 --- a/crates/codegen/xai-grok-tools/src/types/output.rs +++ b/crates/codegen/xai-grok-tools/src/types/output.rs @@ -801,16 +801,14 @@ impl ToolOutput { format!("=== Task {} ===", r.task_id), format!("Command: {}", r.command), format!("Status: {}", r.status), - format!("Started: {}", r.started), + format!("Duration: {:.2}s", r.duration_secs), ]; - if let Some(ref ended) = r.ended { - lines.push(format!("Ended: {}", ended)); - } - lines.push(format!("Duration: {:.2}s", r.duration_secs)); if let Some(code) = r.exit_code { lines.push(format!("Exit Code: {}", code)); } - lines.push(format!("Output File: {}", r.output_file)); + if !r.output_file.is_empty() { + lines.push(format!("Output File: {}", r.output_file)); + } lines.push(String::new()); lines.push("=== Output ===".to_string()); if r.output.is_empty() { @@ -1641,6 +1639,55 @@ mod tests { assert_eq!(json["Result"]["task_id"], "task-1"); assert_eq!(json["Result"]["status"], "running"); } + /// The single-task detail view is duration-only: absolute `started` / + /// `ended` instants stay on the wire struct but must not reach the prompt. + #[test] + fn task_output_prompt_is_duration_only() { + let out = ToolOutput::TaskOutput(TaskOutputOutput::Result(TaskOutputResult { + task_id: "task-1".into(), + command: "sleep 10".into(), + status: "completed".into(), + exit_code: Some(0), + started: "2026-03-09T00:00:00Z".into(), + ended: Some("2026-03-09T00:00:05Z".into()), + duration_secs: 5.0, + output: "hello".into(), + output_file: "/tmp/task-1.log".into(), + truncated: false, + truncation_hint: String::new(), + raw_output_bytes: 5, + })); + let prompt = out.to_prompt_format(); + assert!(prompt.contains("Duration: 5.00s"), "{prompt}"); + assert!(prompt.contains("Output File: /tmp/task-1.log"), "{prompt}"); + assert!( + !prompt.contains("Started") && !prompt.contains("Ended"), + "absolute instants must not be model-visible: {prompt}" + ); + assert!( + !prompt.contains("2026-03-09"), + "no wall-clock date may survive into the prompt: {prompt}" + ); + } + #[test] + fn task_output_prompt_omits_empty_output_file() { + let out = ToolOutput::TaskOutput(TaskOutputOutput::Result(TaskOutputResult { + task_id: "task-2".into(), + command: "true".into(), + status: "completed".into(), + exit_code: Some(0), + started: String::new(), + ended: None, + duration_secs: 0.1, + output: "done".into(), + output_file: String::new(), + truncated: false, + truncation_hint: String::new(), + raw_output_bytes: 4, + })); + let prompt = out.to_prompt_format(); + assert!(!prompt.contains("Output File"), "{prompt}"); + } fn make_result(status: &str, raw_output_bytes: usize) -> TaskOutputResult { TaskOutputResult { task_id: "t".into(), diff --git a/crates/codegen/xai-grok-version/Cargo.toml b/crates/codegen/xai-grok-version/Cargo.toml index 73a7432..47c4962 100644 --- a/crates/codegen/xai-grok-version/Cargo.toml +++ b/crates/codegen/xai-grok-version/Cargo.toml @@ -1,7 +1,7 @@ [package] license = "Apache-2.0" name = "xai-grok-version" -version = "0.2.111" +version = "0.2.112" edition.workspace = true description = "Lockstepped grok CLI version." diff --git a/crates/codegen/xai-grok-workspace-client/src/lib.rs b/crates/codegen/xai-grok-workspace-client/src/lib.rs index a0d068b..77fce41 100644 --- a/crates/codegen/xai-grok-workspace-client/src/lib.rs +++ b/crates/codegen/xai-grok-workspace-client/src/lib.rs @@ -25,6 +25,7 @@ use xai_grok_workspace_types::rpc::code_nav::{ CodeFindDefinitionsReq, CodeFindReferencesReq, CodeGotoDefinitionReq, CodeGotoReferencesReq, CodeIndexStatusReq, CodeIndexStatusResponse, CodeNavResponse, }; +use xai_grok_workspace_types::rpc::export_github::{ExportGithubReq, ExportGithubResponse}; use xai_grok_workspace_types::rpc::fs::{ FsDeleteFileReq, FsExistsData, FsExistsReq, FsListData, FsListReq, FsReadFileData, FsReadFileReq, FsWriteFileReq, GetFilesReq, GetFilesRes, PutFilesReq, PutFilesRes, @@ -349,6 +350,12 @@ impl WorkspaceClient { pub async fn get_files(&self, req: &GetFilesReq) -> Result { self.rpc(req).await } + pub async fn export_github( + &self, + req: &ExportGithubReq, + ) -> Result { + self.rpc(req).await + } pub async fn fs_list(&self, req: &FsListReq) -> Result { self.rpc(req).await } diff --git a/crates/codegen/xai-grok-workspace-types/src/rpc/deploy.rs b/crates/codegen/xai-grok-workspace-types/src/rpc/deploy.rs index 8fe7201..63e261e 100644 --- a/crates/codegen/xai-grok-workspace-types/src/rpc/deploy.rs +++ b/crates/codegen/xai-grok-workspace-types/src/rpc/deploy.rs @@ -1,129 +1 @@ //! App deployment workspace RPC methods. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DeployError { - UrlConflict, - UrlModeration, - IdempotencyConflict, - NotFound, - PermissionDenied, - DeploymentNotInBuildingState, - UnsupportedProjectType, - ProviderUnavailable, - /// The user already owns the maximum number of projects (apps); distinct - /// from the generic `ResourceExhausted` so clients can tell "too many - /// apps" apart from "deploying too fast". - ProjectLimitExceeded, - /// The user exceeded the per-minute deploy rate limit; retry after the - /// window passes. Distinct from the generic `ResourceExhausted` so clients - /// can render the retry hint. - RateLimited, - ArchiveTooLarge, - /// Project was taken down by moderation and cannot be published until - /// an operator reinstates it. - TakenDown, - /// Another deployment is already in progress for this app. - DeploymentInProgress, - Internal, - Unauthenticated, - InvalidArgument, - ResourceExhausted, - DeadlineExceeded, - AlreadyExists, - FailedPrecondition, -} -impl DeployError { - /// Every kind, for exhaustive iteration in tests. - pub const ALL: [DeployError; 20] = [ - Self::UrlConflict, - Self::UrlModeration, - Self::IdempotencyConflict, - Self::NotFound, - Self::PermissionDenied, - Self::DeploymentNotInBuildingState, - Self::UnsupportedProjectType, - Self::ProviderUnavailable, - Self::ProjectLimitExceeded, - Self::RateLimited, - Self::ArchiveTooLarge, - Self::TakenDown, - Self::DeploymentInProgress, - Self::Internal, - Self::Unauthenticated, - Self::InvalidArgument, - Self::ResourceExhausted, - Self::DeadlineExceeded, - Self::AlreadyExists, - Self::FailedPrecondition, - ]; - /// The `RpcError.code` discriminant carried on the workspace RPC envelope. - pub fn wire_code(self) -> &'static str { - match self { - Self::UrlConflict => "deploy_url_conflict", - Self::UrlModeration => "deploy_url_moderation", - Self::IdempotencyConflict => "deploy_idempotency_conflict", - Self::NotFound => "deploy_not_found", - Self::PermissionDenied => "deploy_permission_denied", - Self::DeploymentNotInBuildingState => "deploy_not_in_building_state", - Self::UnsupportedProjectType => "deploy_unsupported_project_type", - Self::ProviderUnavailable => "deploy_provider_unavailable", - Self::ProjectLimitExceeded => "deploy_project_limit_exceeded", - Self::RateLimited => "deploy_rate_limited", - Self::ArchiveTooLarge => "deploy_archive_too_large", - Self::TakenDown => "deploy_taken_down", - Self::DeploymentInProgress => "deploy_deployment_in_progress", - Self::Internal => "deploy_internal", - Self::Unauthenticated => "deploy_unauthenticated", - Self::InvalidArgument => "deploy_invalid_argument", - Self::ResourceExhausted => "deploy_resource_exhausted", - Self::DeadlineExceeded => "deploy_deadline_exceeded", - Self::AlreadyExists => "deploy_already_exists", - Self::FailedPrecondition => "deploy_failed_precondition", - } - } - /// Parse a `RpcError.code` discriminant back into a kind, or `None` when the - /// code is not a deploy error code. - pub fn from_wire_code(code: &str) -> Option { - Some(match code { - "deploy_url_conflict" => Self::UrlConflict, - "deploy_url_moderation" => Self::UrlModeration, - "deploy_idempotency_conflict" => Self::IdempotencyConflict, - "deploy_not_found" => Self::NotFound, - "deploy_permission_denied" => Self::PermissionDenied, - "deploy_not_in_building_state" => Self::DeploymentNotInBuildingState, - "deploy_unsupported_project_type" => Self::UnsupportedProjectType, - "deploy_provider_unavailable" => Self::ProviderUnavailable, - "deploy_project_limit_exceeded" => Self::ProjectLimitExceeded, - "deploy_rate_limited" => Self::RateLimited, - "deploy_archive_too_large" => Self::ArchiveTooLarge, - "deploy_taken_down" => Self::TakenDown, - "deploy_deployment_in_progress" => Self::DeploymentInProgress, - "deploy_internal" => Self::Internal, - "deploy_unauthenticated" => Self::Unauthenticated, - "deploy_invalid_argument" => Self::InvalidArgument, - "deploy_resource_exhausted" => Self::ResourceExhausted, - "deploy_deadline_exceeded" => Self::DeadlineExceeded, - "deploy_already_exists" => Self::AlreadyExists, - "deploy_failed_precondition" => Self::FailedPrecondition, - _ => return None, - }) - } -} -#[cfg(test)] -mod tests { - use super::DeployError; - #[test] - fn deploy_error_kind_wire_code_round_trips() { - for kind in DeployError::ALL { - assert_eq!( - DeployError::from_wire_code(kind.wire_code()), - Some(kind), - "round-trip failed for {kind:?}" - ); - } - } - #[test] - fn deploy_error_kind_rejects_unknown_code() { - assert_eq!(DeployError::from_wire_code("hub_error"), None); - assert_eq!(DeployError::from_wire_code(""), None); - } -} diff --git a/crates/codegen/xai-grok-workspace-types/src/rpc/export_github.rs b/crates/codegen/xai-grok-workspace-types/src/rpc/export_github.rs new file mode 100644 index 0000000..e370a87 --- /dev/null +++ b/crates/codegen/xai-grok-workspace-types/src/rpc/export_github.rs @@ -0,0 +1,98 @@ +use serde::{Deserialize, Serialize}; + +use super::WorkspaceRpc; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExportGithubReq { + pub project_dir: String, + #[serde(default)] + pub repo_full_name: Option, + #[serde(default)] + pub branch: Option, + #[serde(default)] + pub commit_message: Option, +} + +impl WorkspaceRpc for ExportGithubReq { + const METHOD: &'static str = "workspace.export_github"; + type Response = ExportGithubResponse; +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExportGithubResponse { + pub repo_full_name: String, + pub repo_url: String, + pub branch: String, + pub commit_sha: String, + pub no_changes: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExportGithubError { + RepoNotSpecified, + InvalidRepoName, + ProjectDirInvalid, + AuthFailed, + PushRejected, + Timeout, + GitFailed, +} + +impl ExportGithubError { + pub const ALL: [ExportGithubError; 7] = [ + Self::RepoNotSpecified, + Self::InvalidRepoName, + Self::ProjectDirInvalid, + Self::AuthFailed, + Self::PushRejected, + Self::Timeout, + Self::GitFailed, + ]; + + pub fn wire_code(self) -> &'static str { + match self { + Self::RepoNotSpecified => "gh_export_repo_not_specified", + Self::InvalidRepoName => "gh_export_invalid_repo_name", + Self::ProjectDirInvalid => "gh_export_project_dir_invalid", + Self::AuthFailed => "gh_export_auth_failed", + Self::PushRejected => "gh_export_push_rejected", + Self::Timeout => "gh_export_timeout", + Self::GitFailed => "gh_export_git_failed", + } + } + + pub fn from_wire_code(code: &str) -> Option { + Some(match code { + "gh_export_repo_not_specified" => Self::RepoNotSpecified, + "gh_export_invalid_repo_name" => Self::InvalidRepoName, + "gh_export_project_dir_invalid" => Self::ProjectDirInvalid, + "gh_export_auth_failed" => Self::AuthFailed, + "gh_export_push_rejected" => Self::PushRejected, + "gh_export_timeout" => Self::Timeout, + "gh_export_git_failed" => Self::GitFailed, + _ => return None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::ExportGithubError; + + #[test] + fn export_error_wire_code_round_trips() { + for kind in ExportGithubError::ALL { + assert_eq!( + ExportGithubError::from_wire_code(kind.wire_code()), + Some(kind), + "round-trip failed for {kind:?}" + ); + } + } + + #[test] + fn export_error_rejects_unknown_code() { + assert_eq!(ExportGithubError::from_wire_code("hub_error"), None); + assert_eq!(ExportGithubError::from_wire_code(""), None); + } +} diff --git a/crates/codegen/xai-grok-workspace-types/src/rpc/hooks.rs b/crates/codegen/xai-grok-workspace-types/src/rpc/hooks.rs index ce8f760..0397f5d 100644 --- a/crates/codegen/xai-grok-workspace-types/src/rpc/hooks.rs +++ b/crates/codegen/xai-grok-workspace-types/src/rpc/hooks.rs @@ -1,9 +1,5 @@ -//! Hook registry method (`workspace.hook_registry`). -//! -//! `xai_grok_hooks` pulls in `git2`/`reqwest`/`xai-grok-tools`, too heavy for -//! this lean crate, so the response is mirrored here as wire-shape structs -//! rather than re-exported. The shapes must stay byte-identical to the upstream -//! serde attributes (the server round-trips via serde). +//! Wire mirror of the `workspace.hook_registry` response, kept byte-identical to +//! the upstream serde shape so this lean crate avoids the heavy `xai_grok_hooks` dep. use std::collections::HashMap; use std::path::PathBuf; @@ -12,7 +8,6 @@ use serde::{Deserialize, Serialize}; use super::WorkspaceRpc; -/// Request for the loaded hook registry. No parameters. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct HookRegistryReq {} @@ -21,25 +16,12 @@ impl WorkspaceRpc for HookRegistryReq { type Response = HookRegistryWire; } -/// Wire mirror of `xai_grok_hooks::discovery::HookRegistry`. -/// -/// The upstream type keeps its `hooks` map private; the serde shape is -/// `{ "hooks": { "": [, …] } }`. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct HookRegistryWire { pub hooks: HashMap>, } -/// Wire mirror of `xai_grok_hooks::config::HookSpec`. -/// -/// The upstream `matcher` field is `#[serde(skip)]` (compiled regex, never on -/// the wire) and is therefore omitted here; clients recompile it. All other -/// fields keep their snake_case names (the upstream type has no `rename_all`). -/// -/// Must stay in sync with the upstream struct: the lean crate can't depend on -/// `xai-grok-hooks`, so a server-side test (`xai-grok-workspace`'s -/// `hook_spec_wire_covers_all_upstream_fields`) exhaustively destructures every -/// upstream field, failing to compile if upstream adds one. +/// Compiled `matcher` omitted; drift-guarded by `hook_spec_wire_covers_all_upstream_fields`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HookSpecWire { pub name: String, @@ -54,21 +36,17 @@ pub struct HookSpecWire { pub timeout_ms: u64, pub source_dir: PathBuf, pub extra_env: HashMap, + /// `default` decodes a pre-field server as `file`. + #[serde(default = "default_layer")] + pub layer: String, } -/// Wire mirror of `xai_grok_hooks::event::HookEventName`. -/// -/// Serializes to snake_case (matching the upstream derive) and is used as a -/// JSON map key in [`HookRegistryWire`]. `Serialize`/`Deserialize` are -/// hand-written so it works as a serde_json map key and so an unknown event -/// from a newer server is preserved losslessly in [`Unknown`](Self::Unknown): -/// the structured `hook_registry` decode never fails under deploy skew, and -/// distinct unknown events stay distinct map keys. (Not `Copy` — captured -/// `String`.) -/// -/// Known variants must stay in sync with the upstream enum: a server-side test -/// maps every upstream variant here via an exhaustive `match`, failing to -/// compile if upstream adds one. +fn default_layer() -> String { + "file".to_string() +} + +/// Snake_case JSON map key; hand-written serde keeps an unknown event lossless in +/// [`Unknown`](Self::Unknown) so decode never fails under deploy skew. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum HookEventNameWire { SessionStart, @@ -86,12 +64,10 @@ pub enum HookEventNameWire { SubagentEnd, PreCompact, PostCompact, - /// An event string this client does not know, preserved verbatim. Unknown(String), } impl HookEventNameWire { - /// The snake_case wire string (the captured raw value for [`Unknown`]). pub fn as_str(&self) -> &str { match self { Self::SessionStart => "session_start", @@ -142,7 +118,6 @@ impl<'de> Deserialize<'de> for HookEventNameWire { "subagent_end" => Self::SubagentEnd, "pre_compact" => Self::PreCompact, "post_compact" => Self::PostCompact, - // Forward-tolerant: preserve an unknown event verbatim. _ => Self::Unknown(s), }) } @@ -220,7 +195,8 @@ mod tests { "url_raw": null, "timeout_ms": 5000, "source_dir": "/home/u/.grok/hooks", - "extra_env": { "FOO": "bar" } + "extra_env": { "FOO": "bar" }, + "layer": "file" }] } }); diff --git a/crates/codegen/xai-grok-workspace-types/src/rpc/mod.rs b/crates/codegen/xai-grok-workspace-types/src/rpc/mod.rs index 344cd0b..378beb0 100644 --- a/crates/codegen/xai-grok-workspace-types/src/rpc/mod.rs +++ b/crates/codegen/xai-grok-workspace-types/src/rpc/mod.rs @@ -13,6 +13,7 @@ pub mod agents_md; pub mod code_nav; pub mod deploy; pub mod envelope; +pub mod export_github; pub mod fs; pub mod git; pub mod hooks; diff --git a/crates/codegen/xai-grok-workspace/src/error.rs b/crates/codegen/xai-grok-workspace/src/error.rs index 06177b3..0cc2ad7 100644 --- a/crates/codegen/xai-grok-workspace/src/error.rs +++ b/crates/codegen/xai-grok-workspace/src/error.rs @@ -1,7 +1,5 @@ //! Workspace error types. - use crate::capability::CapabilityMode; - /// Errors surfaced by the workspace public API. /// /// `#[non_exhaustive]` so adding new variants is a non-breaking change. @@ -11,68 +9,48 @@ use crate::capability::CapabilityMode; pub enum WorkspaceError { #[error("parent session not found: {0}")] ParentSessionNotFound(String), - #[error("session not found: {0}")] SessionNotFound(String), - #[error("session already exists: {0}")] SessionAlreadyExists(String), - #[error("agent_id must be non-empty")] EmptyAgentId, - #[error("the main session cannot be dropped")] CannotDropMainSession, - #[error("toolset finalization failed: {0}")] Finalize(String), - #[error("capability widening rejected: child {child:?} is not a subset of parent {parent:?}")] CapabilityWidening { parent: CapabilityMode, child: CapabilityMode, }, - #[error("session {caller:?} is not authorised to operate on session {target:?}")] Unauthorized { caller: String, target: String }, - /// A toolset mutation was rejected because the target session has an /// active turn. Retryable at the turn boundary (`after_turn`). #[error("turn active for session {0}; retry the tool-config update at the turn boundary")] TurnActive(String), - #[error("maximum fork depth exceeded for parent session {parent:?}")] MaxDepthExceeded { parent: String }, - #[error("internal task failure: {0}")] JoinError(String), - #[error("invalid hunk action: {0}")] InvalidHunkAction(String), - #[error("hunk action failed: {0}")] HunkActionFailed(String), - /// An error from the server connection or tool server. #[error("hub error: {0}")] HubError(String), - - /// Deploy-service error tagged with its gRPC status class; see - /// [`DeployError`] for how the class crosses the workspace RPC boundary. - /// - /// [`DeployError`]: xai_grok_workspace_types::rpc::deploy::DeployError - #[error("deploy error: {message}")] - DeployError { - kind: xai_grok_workspace_types::rpc::deploy::DeployError, + #[error("github export error: {message}")] + ExportGithub { + kind: xai_grok_workspace_types::rpc::export_github::ExportGithubError, message: String, }, - /// The workspace is draining/shutting down and is no longer accepting new /// sessions. Surfaced when a `bind`/create races a terminal drain so the /// shared upload queue is never torn down out from under a fresh session. #[error("workspace is shutting down; not accepting new sessions")] ShuttingDown, - /// The session's toolset is externally owned — installed by a local /// (shell) bind, its `Terminal` resource is not the session-owned /// backend — so an RPC-driven toolset mutation is refused instead of @@ -81,7 +59,6 @@ pub enum WorkspaceError { #[error("toolset externally owned (local bind), mutation refused: {0}")] ToolsetExternallyOwned(String), } - impl WorkspaceError { /// Low-cardinality `error_kind` metric label: the variant name in /// snake_case; `DeployError` reports its per-kind `wire_code()`. @@ -101,32 +78,17 @@ impl WorkspaceError { Self::InvalidHunkAction(_) => "invalid_hunk_action", Self::HunkActionFailed(_) => "hunk_action_failed", Self::HubError(_) => "hub_error", - Self::DeployError { kind, .. } => kind.wire_code(), + Self::ExportGithub { kind, .. } => kind.wire_code(), Self::ShuttingDown => "shutting_down", Self::ToolsetExternallyOwned(_) => "toolset_externally_owned", } } } - /// Convenience alias for the workspace's primary `Result` type. pub type WorkspaceResult = Result; - #[cfg(test)] mod tests { use super::WorkspaceError; - use xai_grok_workspace_types::rpc::deploy::DeployError; - - #[test] - fn metric_kind_reports_deploy_wire_code() { - for kind in DeployError::ALL { - let err = WorkspaceError::DeployError { - kind, - message: "m".into(), - }; - assert_eq!(err.metric_kind(), kind.wire_code()); - } - } - #[test] fn metric_kind_is_message_free() { let err = WorkspaceError::HubError("something wildly unique 12345".into()); diff --git a/crates/codegen/xai-grok-workspace/src/export_github.rs b/crates/codegen/xai-grok-workspace/src/export_github.rs new file mode 100644 index 0000000..e777731 --- /dev/null +++ b/crates/codegen/xai-grok-workspace/src/export_github.rs @@ -0,0 +1,753 @@ +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use xai_grok_workspace_types::rpc::export_github::{ExportGithubError, ExportGithubResponse}; + +pub const GITHUB_REPO_MAPPING_FILE: &str = ".github_repo"; + +const EXPORT_BUDGET: Duration = Duration::from_secs(120); + +const DEFAULT_BRANCH: &str = "main"; +const AUTHOR_NAME: &str = "Grok"; +const AUTHOR_EMAIL: &str = "grok-export@users.noreply.github.com"; + +const SEED_GITIGNORE: &str = "node_modules/\n.project_id\n.github_repo\n.env\n.env.*\n"; + +pub struct ExportGithubParams<'a> { + pub project_dir: &'a Path, + pub repo_full_name: Option<&'a str>, + pub remote_url_base: &'a str, + pub web_url_base: &'a str, + pub branch: Option<&'a str>, + pub commit_message: Option<&'a str>, +} + +#[derive(Debug)] +pub struct ExportGithubFailure { + pub kind: ExportGithubError, + pub message: String, +} + +impl ExportGithubFailure { + fn new(kind: ExportGithubError, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } +} + +fn non_empty(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|s| !s.is_empty()) +} + +fn validate_repo_full_name(name: &str) -> Result<(), ExportGithubFailure> { + let parts: Vec<&str> = name.split('/').collect(); + let valid_segment = |s: &str| { + !s.is_empty() + && s != "." + && s != ".." + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) + }; + if parts.len() == 2 && parts.iter().all(|p| valid_segment(p)) { + Ok(()) + } else { + Err(ExportGithubFailure::new( + ExportGithubError::InvalidRepoName, + format!("repository must be 'owner/name', got {name:?}"), + )) + } +} + +pub async fn run_export( + params: ExportGithubParams<'_>, +) -> Result { + let dir = params.project_dir.to_owned(); + match tokio::time::timeout(EXPORT_BUDGET, run_export_inner(params)).await { + Ok(result) => result, + Err(_) => { + remove_stale_git_locks(&dir); + Err(ExportGithubFailure::new( + ExportGithubError::Timeout, + format!("export exceeded {}s", EXPORT_BUDGET.as_secs()), + )) + } + } +} + +fn remove_stale_git_locks(dir: &Path) { + for lock in ["index.lock", "HEAD.lock", "config.lock"] { + let path = dir.join(".git").join(lock); + match std::fs::remove_file(&path) { + Ok(()) => { + tracing::warn!(lock = %path.display(), "removed stale git lock after timeout"); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + tracing::warn!(lock = %path.display(), error = %e, "failed to remove git lock"); + } + } + } +} + +async fn run_export_inner( + params: ExportGithubParams<'_>, +) -> Result { + let dir = params.project_dir; + if !dir.is_dir() { + return Err(ExportGithubFailure::new( + ExportGithubError::ProjectDirInvalid, + format!("project dir does not exist: {}", dir.display()), + )); + } + + let repo_full_name = resolve_repo_mapping(dir, non_empty(params.repo_full_name))?; + let remote_url = format!( + "{}/{}.git", + params.remote_url_base.trim_end_matches('/'), + repo_full_name + ); + + ensure_repo(dir, non_empty(params.branch)).await?; + seed_gitignore_if_absent(dir)?; + ensure_export_excludes(dir)?; + untrack_excluded_paths(dir).await?; + + git(dir, &["add", "-A"]).await?; + let no_changes = commit_if_dirty(dir, non_empty(params.commit_message)).await?; + let branch = current_branch(dir).await?; + let commit_sha = match git(dir, &["rev-parse", "HEAD"]).await { + Ok(sha) => sha, + Err(_) if no_changes => { + return Err(ExportGithubFailure::new( + ExportGithubError::GitFailed, + "repository has no commits and no changes to export", + )); + } + Err(e) => return Err(e), + }; + + set_remote(dir, &remote_url).await?; + push(dir, &branch).await?; + + Ok(ExportGithubResponse { + repo_url: format!( + "{}/{}", + params.web_url_base.trim_end_matches('/'), + repo_full_name + ), + repo_full_name, + branch, + commit_sha, + no_changes, + }) +} + +fn resolve_repo_mapping( + dir: &Path, + requested: Option<&str>, +) -> Result { + let mapping_path = dir.join(GITHUB_REPO_MAPPING_FILE); + let stored = std::fs::read_to_string(&mapping_path) + .ok() + .and_then(|s| s.lines().next().map(|l| l.trim().to_owned())) + .filter(|s| !s.is_empty()); + + let repo = match (requested, stored) { + (Some(req), _) => req.to_owned(), + (None, Some(stored)) => stored, + (None, None) => { + return Err(ExportGithubFailure::new( + ExportGithubError::RepoNotSpecified, + "no repository named and no .github_repo mapping exists", + )); + } + }; + validate_repo_full_name(&repo)?; + + std::fs::write(&mapping_path, format!("{repo}\n")).map_err(|e| { + ExportGithubFailure::new( + ExportGithubError::GitFailed, + format!("writing {GITHUB_REPO_MAPPING_FILE}: {e}"), + ) + })?; + Ok(repo) +} + +async fn ensure_repo(dir: &Path, branch: Option<&str>) -> Result<(), ExportGithubFailure> { + if !dir.join(".git").exists() { + git(dir, &["init", "-b", branch.unwrap_or(DEFAULT_BRANCH)]).await?; + return Ok(()); + } + let Some(requested) = branch else { + return Ok(()); + }; + if git(dir, &["rev-parse", "--verify", "HEAD"]).await.is_ok() { + git(dir, &["checkout", "-B", requested]).await?; + } else { + let target = format!("refs/heads/{requested}"); + git(dir, &["symbolic-ref", "HEAD", &target]).await?; + } + Ok(()) +} + +fn seed_gitignore_if_absent(dir: &Path) -> Result<(), ExportGithubFailure> { + let path = dir.join(".gitignore"); + if path.exists() { + return Ok(()); + } + std::fs::write(&path, SEED_GITIGNORE).map_err(|e| { + ExportGithubFailure::new( + ExportGithubError::GitFailed, + format!("seeding .gitignore: {e}"), + ) + }) +} + +fn ensure_export_excludes(dir: &Path) -> Result<(), ExportGithubFailure> { + let io_err = |e: std::io::Error| { + ExportGithubFailure::new( + ExportGithubError::GitFailed, + format!("writing .git/info/exclude: {e}"), + ) + }; + let info_dir = dir.join(".git").join("info"); + std::fs::create_dir_all(&info_dir).map_err(io_err)?; + let exclude_path = info_dir.join("exclude"); + let existing = std::fs::read_to_string(&exclude_path).unwrap_or_default(); + let missing: Vec<&str> = SEED_GITIGNORE + .lines() + .filter(|rule| !existing.lines().any(|line| line.trim() == *rule)) + .collect(); + if missing.is_empty() { + return Ok(()); + } + let separator = if existing.is_empty() || existing.ends_with('\n') { + "" + } else { + "\n" + }; + std::fs::write( + &exclude_path, + format!("{existing}{separator}{}\n", missing.join("\n")), + ) + .map_err(io_err) +} + +async fn untrack_excluded_paths(dir: &Path) -> Result<(), ExportGithubFailure> { + let mut args = vec![ + "rm", + "-r", + "-f", + "--cached", + "--ignore-unmatch", + "--quiet", + "--", + ]; + args.extend(SEED_GITIGNORE.lines()); + git(dir, &args).await?; + Ok(()) +} + +async fn commit_if_dirty(dir: &Path, message: Option<&str>) -> Result { + let status = git(dir, &["status", "--porcelain"]).await?; + if status.is_empty() { + return Ok(true); + } + let message = message.unwrap_or("Export from Grok"); + git(dir, &["commit", "-m", message]).await?; + Ok(false) +} + +async fn current_branch(dir: &Path) -> Result { + let branch = git(dir, &["rev-parse", "--abbrev-ref", "HEAD"]).await?; + if branch == "HEAD" { + Ok(DEFAULT_BRANCH.to_owned()) + } else { + Ok(branch) + } +} + +async fn set_remote(dir: &Path, url: &str) -> Result<(), ExportGithubFailure> { + if git(dir, &["remote", "get-url", "origin"]).await.is_ok() { + git(dir, &["remote", "set-url", "origin", url]).await?; + } else { + git(dir, &["remote", "add", "origin", url]).await?; + } + Ok(()) +} + +async fn push(dir: &Path, branch: &str) -> Result<(), ExportGithubFailure> { + let refspec = format!("HEAD:refs/heads/{branch}"); + match git(dir, &["push", "-u", "origin", &refspec]).await { + Ok(_) => Ok(()), + Err(failure) => Err(classify_push_failure(failure)), + } +} + +const PUSH_REJECTED_MARKERS: [&str; 5] = [ + "non-fast-forward", + "fetch first", + "[rejected]", + "updates were rejected", + "failed to push some refs", +]; + +const PUSH_AUTH_MARKERS: [&str; 8] = [ + "authentication failed", + "could not read username", + "permission to", + "http basic: access denied", + "invalid username or token", + "authentication required", + "returned error: 403", + "returned error: 401", +]; + +fn classify_push_failure(failure: ExportGithubFailure) -> ExportGithubFailure { + let lower = failure.message.to_lowercase(); + let kind = if PUSH_AUTH_MARKERS.iter().any(|m| lower.contains(m)) { + ExportGithubError::AuthFailed + } else if PUSH_REJECTED_MARKERS.iter().any(|m| lower.contains(m)) { + ExportGithubError::PushRejected + } else { + tracing::warn!(message = %failure.message, "git push failed with unclassified stderr"); + ExportGithubError::GitFailed + }; + ExportGithubFailure::new(kind, failure.message) +} + +async fn git(dir: &Path, args: &[&str]) -> Result { + let mut cmd = xai_tty_utils::git_command(); + cmd.current_dir(dir) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_AUTHOR_NAME", AUTHOR_NAME) + .env("GIT_AUTHOR_EMAIL", AUTHOR_EMAIL) + .env("GIT_COMMITTER_NAME", AUTHOR_NAME) + .env("GIT_COMMITTER_EMAIL", AUTHOR_EMAIL) + .args(args); + let mut async_cmd = tokio::process::Command::from(cmd); + async_cmd.kill_on_drop(true); + let output = async_cmd.output().await.map_err(|e| { + ExportGithubFailure::new( + ExportGithubError::GitFailed, + format!("spawning git {}: {e}", args.join(" ")), + ) + })?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) + } else { + Err(ExportGithubFailure::new( + ExportGithubError::GitFailed, + format!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + ), + )) + } +} + +pub fn mapping_file_path(project_dir: &Path) -> PathBuf { + project_dir.join(GITHUB_REPO_MAPPING_FILE) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp_dir(name: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("gh-export-test-{name}-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn bare_remote(base: &Path, full_name: &str) -> String { + let repo_path = base.join(format!("{full_name}.git")); + std::fs::create_dir_all(&repo_path).unwrap(); + let mut cmd = xai_tty_utils::git_command(); + let out = cmd + .args(["init", "--bare"]) + .current_dir(&repo_path) + .output() + .unwrap(); + assert!(out.status.success()); + format!("file://{}", base.display()) + } + + fn params<'a>( + project: &'a Path, + repo: Option<&'a str>, + base: &'a str, + ) -> ExportGithubParams<'a> { + ExportGithubParams { + project_dir: project, + repo_full_name: repo, + remote_url_base: base, + web_url_base: "https://github.com", + branch: None, + commit_message: None, + } + } + + fn remote_head(base: &Path, full_name: &str) -> String { + let mut cmd = xai_tty_utils::git_command(); + let out = cmd + .args(["rev-parse", "refs/heads/main"]) + .current_dir(base.join(format!("{full_name}.git"))) + .output() + .unwrap(); + assert!(out.status.success()); + String::from_utf8_lossy(&out.stdout).trim().to_owned() + } + + #[tokio::test] + async fn first_export_inits_commits_and_pushes() { + let remote_base = tmp_dir("remote"); + let base = bare_remote(&remote_base, "user/app"); + let project = tmp_dir("project"); + std::fs::write(project.join("index.html"), "").unwrap(); + + let res = run_export(params(&project, Some("user/app"), &base)) + .await + .unwrap(); + + assert_eq!(res.repo_full_name, "user/app"); + assert_eq!(res.repo_url, "https://github.com/user/app"); + assert_eq!(res.branch, "main"); + assert!(!res.no_changes); + assert_eq!(remote_head(&remote_base, "user/app"), res.commit_sha); + assert_eq!( + std::fs::read_to_string(project.join(GITHUB_REPO_MAPPING_FILE)).unwrap(), + "user/app\n" + ); + assert!(project.join(".gitignore").exists()); + } + + #[test] + fn stale_git_locks_are_removed_after_timeout() { + let project = tmp_dir("project"); + let git_dir = project.join(".git"); + std::fs::create_dir_all(&git_dir).unwrap(); + for lock in ["index.lock", "HEAD.lock", "config.lock"] { + std::fs::write(git_dir.join(lock), "").unwrap(); + } + std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + + remove_stale_git_locks(&project); + + for lock in ["index.lock", "HEAD.lock", "config.lock"] { + assert!(!git_dir.join(lock).exists(), "{lock} should be removed"); + } + assert!(git_dir.join("HEAD").exists()); + } + + #[tokio::test] + async fn empty_branch_and_commit_message_fall_back_to_defaults() { + let remote_base = tmp_dir("remote"); + let base = bare_remote(&remote_base, "user/app"); + let project = tmp_dir("project"); + std::fs::write(project.join("index.html"), "").unwrap(); + + let mut p = params(&project, Some("user/app"), &base); + p.branch = Some(""); + p.commit_message = Some(" "); + let res = run_export(p).await.unwrap(); + + assert_eq!(res.branch, "main"); + assert!(!res.no_changes); + assert_eq!(remote_head(&remote_base, "user/app"), res.commit_sha); + } + + #[tokio::test] + async fn preexisting_gitignore_still_excludes_sensitive_files() { + let remote_base = tmp_dir("remote"); + let base = bare_remote(&remote_base, "user/app"); + let project = tmp_dir("project"); + std::fs::write(project.join(".gitignore"), "dist/\n").unwrap(); + std::fs::write(project.join("index.html"), "").unwrap(); + std::fs::write(project.join(".env"), "SECRET=1").unwrap(); + + run_export(params(&project, Some("user/app"), &base)) + .await + .unwrap(); + + let tracked = git(&project, &["ls-tree", "-r", "--name-only", "HEAD"]) + .await + .unwrap(); + assert!(tracked.contains("index.html")); + assert!(!tracked.contains(".env"), "committed .env: {tracked}"); + assert!( + !tracked.contains(GITHUB_REPO_MAPPING_FILE), + "committed mapping file: {tracked}" + ); + assert_eq!( + std::fs::read_to_string(project.join(".gitignore")).unwrap(), + "dist/\n" + ); + } + + #[tokio::test] + async fn second_export_reuses_mapping_and_reports_no_changes() { + let remote_base = tmp_dir("remote"); + let base = bare_remote(&remote_base, "user/app"); + let project = tmp_dir("project"); + std::fs::write(project.join("main.py"), "print('hi')").unwrap(); + + let first = run_export(params(&project, Some("user/app"), &base)) + .await + .unwrap(); + let second = run_export(params(&project, None, &base)).await.unwrap(); + + assert_eq!(second.repo_full_name, "user/app"); + assert!(second.no_changes); + assert_eq!(second.commit_sha, first.commit_sha); + } + + #[tokio::test] + async fn changed_files_produce_a_new_commit_on_the_same_repo() { + let remote_base = tmp_dir("remote"); + let base = bare_remote(&remote_base, "user/app"); + let project = tmp_dir("project"); + std::fs::write(project.join("a.txt"), "one").unwrap(); + + let first = run_export(params(&project, Some("user/app"), &base)) + .await + .unwrap(); + std::fs::write(project.join("a.txt"), "two").unwrap(); + let second = run_export(params(&project, None, &base)).await.unwrap(); + + assert!(!second.no_changes); + assert_ne!(second.commit_sha, first.commit_sha); + assert_eq!(remote_head(&remote_base, "user/app"), second.commit_sha); + } + + #[tokio::test] + async fn request_repo_overrides_stale_mapping() { + let remote_base = tmp_dir("remote"); + let base = bare_remote(&remote_base, "user/app"); + bare_remote(&remote_base, "user/other"); + let project = tmp_dir("project"); + std::fs::write(project.join("a.txt"), "one").unwrap(); + + run_export(params(&project, Some("user/app"), &base)) + .await + .unwrap(); + let res = run_export(params(&project, Some("user/other"), &base)) + .await + .unwrap(); + + assert_eq!(res.repo_full_name, "user/other"); + assert_eq!( + std::fs::read_to_string(project.join(GITHUB_REPO_MAPPING_FILE)).unwrap(), + "user/other\n" + ); + } + + #[tokio::test] + async fn missing_repo_and_mapping_is_a_typed_error() { + let project = tmp_dir("project"); + std::fs::write(project.join("a.txt"), "one").unwrap(); + for repo in [None, Some(""), Some(" ")] { + let err = run_export(params(&project, repo, "file:///nowhere")) + .await + .unwrap_err(); + assert_eq!( + err.kind, + ExportGithubError::RepoNotSpecified, + "for {repo:?}" + ); + } + } + + #[tokio::test] + async fn invalid_repo_name_is_rejected_before_any_git_runs() { + let project = tmp_dir("project"); + std::fs::write(project.join("a.txt"), "one").unwrap(); + for bad in ["justname", "a/b/c", "../evil/repo", "owner/na me"] { + let err = run_export(params(&project, Some(bad), "file:///nowhere")) + .await + .unwrap_err(); + assert_eq!(err.kind, ExportGithubError::InvalidRepoName, "for {bad:?}"); + } + assert!(!project.join(".git").exists()); + } + + #[tokio::test] + async fn existing_gitignore_is_not_overwritten() { + let remote_base = tmp_dir("remote"); + let base = bare_remote(&remote_base, "user/app"); + let project = tmp_dir("project"); + std::fs::write(project.join("a.txt"), "one").unwrap(); + std::fs::write(project.join(".gitignore"), "custom/\n").unwrap(); + + run_export(params(&project, Some("user/app"), &base)) + .await + .unwrap(); + + assert_eq!( + std::fs::read_to_string(project.join(".gitignore")).unwrap(), + "custom/\n" + ); + } + + #[tokio::test] + async fn non_fast_forward_push_maps_to_push_rejected() { + let remote_base = tmp_dir("remote"); + let base = bare_remote(&remote_base, "user/app"); + + let project_a = tmp_dir("project-a"); + std::fs::write(project_a.join("a.txt"), "one").unwrap(); + run_export(params(&project_a, Some("user/app"), &base)) + .await + .unwrap(); + + let project_b = tmp_dir("project-b"); + std::fs::write(project_b.join("b.txt"), "unrelated history").unwrap(); + let err = run_export(params(&project_b, Some("user/app"), &base)) + .await + .unwrap_err(); + assert_eq!(err.kind, ExportGithubError::PushRejected); + } + + #[tokio::test] + async fn previously_tracked_secrets_are_untracked_on_export() { + let remote_base = tmp_dir("remote"); + let base = bare_remote(&remote_base, "user/app"); + let project = tmp_dir("project"); + std::fs::write(project.join("index.html"), "").unwrap(); + std::fs::write(project.join(".env"), "SECRET=1").unwrap(); + std::fs::write(project.join(".project_id"), "proj-123").unwrap(); + git(&project, &["init", "-b", "main"]).await.unwrap(); + git(&project, &["add", "-A"]).await.unwrap(); + git(&project, &["commit", "-m", "seed"]).await.unwrap(); + + let res = run_export(params(&project, Some("user/app"), &base)) + .await + .unwrap(); + + let tracked = git(&project, &["ls-tree", "-r", "--name-only", "HEAD"]) + .await + .unwrap(); + assert!(tracked.contains("index.html")); + assert!(!tracked.contains(".env"), "still tracks .env: {tracked}"); + assert!( + !tracked.contains(".project_id"), + "still tracks .project_id: {tracked}" + ); + assert!(!res.no_changes); + assert!(project.join(".env").exists()); + assert!(project.join(".project_id").exists()); + } + + #[tokio::test] + async fn requested_branch_is_used_on_reexport() { + let remote_base = tmp_dir("remote"); + let base = bare_remote(&remote_base, "user/app"); + let project = tmp_dir("project"); + std::fs::write(project.join("a.txt"), "one").unwrap(); + + run_export(params(&project, Some("user/app"), &base)) + .await + .unwrap(); + + std::fs::write(project.join("a.txt"), "two").unwrap(); + let mut second = params(&project, None, &base); + second.branch = Some("feature"); + let res = run_export(second).await.unwrap(); + + assert_eq!(res.branch, "feature"); + let mut cmd = xai_tty_utils::git_command(); + let out = cmd + .args(["rev-parse", "refs/heads/feature"]) + .current_dir(remote_base.join("user/app.git")) + .output() + .unwrap(); + assert!(out.status.success()); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), res.commit_sha); + } + + #[test] + fn push_failure_stderr_classification_table() { + let cases = [ + ( + "! [rejected] main -> main (non-fast-forward)", + ExportGithubError::PushRejected, + ), + ( + "hint: Updates were rejected because the remote contains work that you do not have locally", + ExportGithubError::PushRejected, + ), + ( + "error: failed to push some refs to 'https://github.com/user/app.git'", + ExportGithubError::PushRejected, + ), + ( + "fatal: Authentication failed for 'https://github.com/user/app.git/'", + ExportGithubError::AuthFailed, + ), + ( + "fatal: could not read Username for 'https://github.com': terminal prompts disabled", + ExportGithubError::AuthFailed, + ), + ( + "remote: Permission to user/app.git denied to some-bot.", + ExportGithubError::AuthFailed, + ), + ( + "The requested URL returned error: 403", + ExportGithubError::AuthFailed, + ), + ( + "The requested URL returned error: 401", + ExportGithubError::AuthFailed, + ), + ( + "remote: HTTP Basic: Access denied", + ExportGithubError::AuthFailed, + ), + ( + "remote: Invalid username or token.", + ExportGithubError::AuthFailed, + ), + ( + "fatal: Authentication required", + ExportGithubError::AuthFailed, + ), + ( + "remote: Permission to user/app.git denied to some-bot.\nfatal: unable to access 'https://github.com/user/app.git/': The requested URL returned error: 403\nerror: failed to push some refs to 'https://github.com/user/app.git'", + ExportGithubError::AuthFailed, + ), + ( + "fatal: Authentication failed for 'https://github.com/user/app.git/'\nerror: failed to push some refs to 'https://github.com/user/app.git'", + ExportGithubError::AuthFailed, + ), + ( + "! [rejected] main -> main (fetch first)\nerror: failed to push some refs to 'https://github.com/user403/app401.git'\nhint: Updates were rejected because the remote contains work that you do not have locally", + ExportGithubError::PushRejected, + ), + ( + "fatal: unable to access 'https://github.com/user/app.git/': Could not resolve host: github.com", + ExportGithubError::GitFailed, + ), + ]; + for (stderr, expected) in cases { + let failure = classify_push_failure(ExportGithubFailure::new( + ExportGithubError::GitFailed, + stderr.to_owned(), + )); + assert_eq!(failure.kind, expected, "for {stderr:?}"); + } + } + + #[tokio::test] + async fn missing_project_dir_is_a_typed_error() { + let ghost = std::env::temp_dir().join(format!("gh-export-ghost-{}", uuid::Uuid::new_v4())); + let err = run_export(params(&ghost, Some("user/app"), "file:///nowhere")) + .await + .unwrap_err(); + assert_eq!(err.kind, ExportGithubError::ProjectDirInvalid); + } +} diff --git a/crates/codegen/xai-grok-workspace/src/handle.rs b/crates/codegen/xai-grok-workspace/src/handle.rs index 6ac20c6..abb2c58 100644 --- a/crates/codegen/xai-grok-workspace/src/handle.rs +++ b/crates/codegen/xai-grok-workspace/src/handle.rs @@ -7602,6 +7602,7 @@ pub(crate) mod tests { timeout_ms: 10_000, source_dir: std::path::PathBuf::from("/tmp"), extra_env: std::collections::HashMap::new(), + layer: xai_grok_hooks::config::HookProvenance::File, }; handle.shared.hook_registry.write().append_specs(vec![spec]); } diff --git a/crates/codegen/xai-grok-workspace/src/hub_server.rs b/crates/codegen/xai-grok-workspace/src/hub_server.rs index ae06115..156089c 100644 --- a/crates/codegen/xai-grok-workspace/src/hub_server.rs +++ b/crates/codegen/xai-grok-workspace/src/hub_server.rs @@ -526,11 +526,28 @@ impl WorkspaceRpcHandler { let cwd = self.workspace.root_cwd()?; let mut results = Vec::new(); for ref_path in &refs { - let full_path = if std::path::Path::new(ref_path).is_absolute() { + let requested_path = if std::path::Path::new(ref_path).is_absolute() { std::path::PathBuf::from(ref_path) } else { cwd.join(ref_path) }; + let full_path = match self + .workspace + .confine_to_workspace_root(&requested_path) + .await + { + Ok((confined, _)) => confined, + Err(e) => { + results.push(serde_json::json!({ + "path": requested_path.to_string_lossy(), + "ref": ref_path, + "exists": false, + "content": Value::Null, + "error": e.to_string(), + })); + continue; + } + }; let exists = full_path.exists(); let content = if exists { tokio::fs::read_to_string(&full_path).await.ok() @@ -601,6 +618,9 @@ impl WorkspaceRpcHandler { ); Ok(Value::Array(plugins)) } + ::METHOD => { + dispatch_op::(params, &self.workspace, None).await + } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } @@ -1169,7 +1189,9 @@ impl ToolServerHandler for WorkspaceRpcHandler { mod tests { use super::*; use crate::capability::CapabilityMode; - use crate::handle::tests::{background_capable_cfg, make_handle, start_background_sleep}; + use crate::handle::tests::{ + background_capable_cfg, make_confining_handle, make_handle, start_background_sleep, + }; use xai_grok_tools::implementations::grok_build::scheduler::types::{ ScheduledTask, SchedulerState, }; @@ -2498,6 +2520,34 @@ mod tests { ); } #[tokio::test] + async fn dispatch_resolve_file_references_rejects_outside_root_when_confined() { + let handle = make_confining_handle(); + let handler = WorkspaceRpcHandler::new(handle); + let secret = std::env::temp_dir().join("h1_3885911_outside_secret.txt"); + std::fs::write(&secret, "OUTSIDE_SECRET").unwrap(); + let params = serde_json::json!({ + "refs": [secret.to_string_lossy(), "../escape.txt"] + }); + let result = handler + .dispatch("workspace.resolve_file_references", params, None) + .await + .expect("dispatch itself should succeed"); + let arr = result.as_array().expect("results array"); + assert_eq!(arr.len(), 2); + for entry in arr { + assert_eq!(entry["exists"], serde_json::Value::Bool(false)); + assert_eq!(entry["content"], serde_json::Value::Null); + assert!( + entry["error"] + .as_str() + .unwrap_or_default() + .contains("escapes workspace root"), + "escape should be rejected, not read: {entry:?}" + ); + } + std::fs::remove_file(&secret).ok(); + } + #[tokio::test] async fn handle_hook_pause_resume_are_noops() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); @@ -3057,6 +3107,7 @@ mod tests { ::METHOD, ::METHOD, ::METHOD, + ::METHOD, ]; let skipped_global_db_mutators = [ ::METHOD, diff --git a/crates/codegen/xai-grok-workspace/src/lib.rs b/crates/codegen/xai-grok-workspace/src/lib.rs index a769f99..230a5ba 100644 --- a/crates/codegen/xai-grok-workspace/src/lib.rs +++ b/crates/codegen/xai-grok-workspace/src/lib.rs @@ -15,6 +15,7 @@ pub mod diag_server; pub mod discovery; pub mod envrc; pub mod error; +pub mod export_github; pub mod file_system; pub mod folder_trust; pub mod foreign_sessions; diff --git a/crates/codegen/xai-grok-workspace/src/permission/manager.rs b/crates/codegen/xai-grok-workspace/src/permission/manager.rs index c81c649..89dfd48 100644 --- a/crates/codegen/xai-grok-workspace/src/permission/manager.rs +++ b/crates/codegen/xai-grok-workspace/src/permission/manager.rs @@ -19,8 +19,8 @@ use crate::permission::gate_preflight::GatePreflight; use crate::permission::policy::{CompiledPolicy, ShellWord}; use crate::permission::prompter::{AcpPrompter, PromptOutcome}; use crate::permission::shell_access::{ - command_write_paths_in_tree, edit_target_requires_prompt, is_safe_write_sink, - tree_has_opaque_shell, words_are_opaque_shell, + command_write_paths_in_tree, edit_target_protection, is_safe_write_sink, tree_has_opaque_shell, + words_are_opaque_shell, }; use crate::permission::state::{PermissionState, load_state_from_disk, persist_state}; use crate::permission::types::{ @@ -1569,15 +1569,15 @@ fn spawn_permission_manager_with_pin( context.display_cwd.as_deref(), path, ); - edit_target_requires_prompt(&resolved) + edit_target_protection(&resolved) } // Direct workspace callers predate per-request context and execute // against the manager cwd; the shell always supplies context. (AccessKind::Edit(path), None) => { let resolved = resolve_model_path(cwd.as_path(), None, path); - edit_target_requires_prompt(&resolved) + edit_target_protection(&resolved) } - _ => false, + _ => None, }; // Evaluate managed policy (direct access + per-segment Bash command @@ -1627,7 +1627,7 @@ fn spawn_permission_manager_with_pin( // Ask floors fall through so managed Ask / shell-file Ask stay binding. if !policy_forced_prompt && !shell_forced_prompt - && !protected_edit + && protected_edit.is_none() && let Some((decision, reason)) = session_grant_pre_decision( &access, bash_evaluation.as_ref(), @@ -1651,7 +1651,7 @@ fn spawn_permission_manager_with_pin( if auto_mode && !policy_forced_prompt && !shell_forced_prompt - && !protected_edit + && protected_edit.is_none() && !bash_request_floor_requires_prompt(bash_evaluation.as_ref()) && matches!(policy_decision, Some(Decision::Allow)) { @@ -1681,8 +1681,8 @@ fn spawn_permission_manager_with_pin( AutoFastPath, ClassifierVerdict, access_requires_user_interaction, auto_mode_fast_path, }; - let needs_user = - protected_edit || access_requires_user_interaction(&tool_name, &access); + let needs_user = protected_edit.is_some() + || access_requires_user_interaction(&tool_name, &access); let fast = auto_mode_fast_path(&access, &tool_name, needs_user); match fast { AutoFastPath::Allow => { @@ -1901,7 +1901,7 @@ fn spawn_permission_manager_with_pin( ); } Some(Decision::Allow) - if protected_edit + if protected_edit.is_some() || bash_request_floor_requires_prompt(bash_evaluation.as_ref()) => { tracing::info!( @@ -1961,7 +1961,7 @@ fn spawn_permission_manager_with_pin( ) .map(|d| (d, reasons::PERSISTED_GRANT)), AccessKind::Edit(_) => { - if allow_edits_for_session && !protected_edit { + if allow_edits_for_session && protected_edit.is_none() { Some((Decision::Allow, reasons::PERSISTED_GRANT)) } else { match state.edit_policy { @@ -2117,7 +2117,7 @@ fn spawn_permission_manager_with_pin( // (e.g. `curl … && sh` must not become two separate // prompts for `curl …` then `sh`). let prompt_outcome = tokio::select! { - outcome = prompter.request(&access, &tool_call_update) => outcome, + outcome = prompter.request(&access, &tool_call_update, protected_edit) => outcome, _ = respond_to.closed() => PromptOutcome::Cancelled, }; @@ -2173,7 +2173,7 @@ fn spawn_permission_manager_with_pin( _ => { // Non-bash access kinds keep the single-prompt flow. let prompt_outcome = tokio::select! { - outcome = prompter.request(&access, &tool_call_update) => outcome, + outcome = prompter.request(&access, &tool_call_update, protected_edit) => outcome, _ = respond_to.closed() => PromptOutcome::Cancelled, }; let (decision, outcome_str) = match &prompt_outcome { @@ -5123,46 +5123,43 @@ mod tests { let local = tokio::task::LocalSet::new(); local .run_until(async { - let mut auto = crate::permission::types::PermissionConfig::new(vec![]); - auto.prompt_policy = PromptPolicy::Auto; - let allow = crate::permission::types::PermissionConfig::new(vec![PermissionRule { - action: RuleAction::Allow, - tool: ToolFilter::Edit, - pattern: None, - pattern_mode: Default::default(), - }]); - let mut deny = crate::permission::types::PermissionConfig::new(vec![]); - deny.prompt_policy = PromptPolicy::Deny; + for path in ["/etc/hosts", "/home/user/.grok/hooks/evil.json"] { + let mut auto = crate::permission::types::PermissionConfig::new(vec![]); + auto.prompt_policy = PromptPolicy::Auto; + let allow = + crate::permission::types::PermissionConfig::new(vec![PermissionRule { + action: RuleAction::Allow, + tool: ToolFilter::Edit, + pattern: None, + pattern_mode: Default::default(), + }]); + let mut deny = crate::permission::types::PermissionConfig::new(vec![]); + deny.prompt_policy = PromptPolicy::Deny; - for (name, config, expected_prompts, policy_deny) in [ - ("auto", auto, 1, false), - ("configured allow", allow, 1, false), - ("dontAsk", deny, 0, true), - ] { - let tmp = tempfile::tempdir().unwrap(); - let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap(); - let client = RecordingClient::default(); - let prompts = client.prompts.clone(); - let (mgr, _events) = manager_with_recording_client( - &cwd, - Some(config), - client, - ClientType::Generic, - ); - let decision = mgr - .request( - AccessKind::Edit("/etc/hosts".into()), - tool_call(), - None, - None, - None, - ) - .await; - assert_eq!(prompts.borrow().len(), expected_prompts, "{name}"); - if policy_deny { - assert!(matches!(decision, Decision::PolicyDeny(_)), "{name}"); - } else { - assert!(matches!(decision, Decision::Reject(_)), "{name}"); + for (name, config, expected_prompts, policy_deny) in [ + ("auto", auto, 1, false), + ("configured allow", allow, 1, false), + ("dontAsk", deny, 0, true), + ] { + let tmp = tempfile::tempdir().unwrap(); + let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap(); + let client = RecordingClient::default(); + let prompts = client.prompts.clone(); + let (mgr, _events) = manager_with_recording_client( + &cwd, + Some(config), + client, + ClientType::Generic, + ); + let decision = mgr + .request(AccessKind::Edit(path.into()), tool_call(), None, None, None) + .await; + assert_eq!(prompts.borrow().len(), expected_prompts, "{name} {path}"); + if policy_deny { + assert!(matches!(decision, Decision::PolicyDeny(_)), "{name} {path}"); + } else { + assert!(matches!(decision, Decision::Reject(_)), "{name} {path}"); + } } } }) diff --git a/crates/codegen/xai-grok-workspace/src/permission/mod.rs b/crates/codegen/xai-grok-workspace/src/permission/mod.rs index 04f85d2..c99591d 100644 --- a/crates/codegen/xai-grok-workspace/src/permission/mod.rs +++ b/crates/codegen/xai-grok-workspace/src/permission/mod.rs @@ -43,6 +43,7 @@ pub use prompter::{ PromptOutcome, is_enable_always_approve_option, mcp_pretty_name_if_qualified, mcp_titleize_segment, mcp_tool_action, mcp_tool_display_name, }; +pub use shell_access::{ProtectedEditPermission, ProtectedEditReason}; pub use state::PermissionState; pub use state::cleanup_stale_permission_state; pub use types::{AccessKind, ClientType, Decision, PermissionCommand, PermissionEvent}; diff --git a/crates/codegen/xai-grok-workspace/src/permission/prompter.rs b/crates/codegen/xai-grok-workspace/src/permission/prompter.rs index 5f4d2c8..4760f92 100644 --- a/crates/codegen/xai-grok-workspace/src/permission/prompter.rs +++ b/crates/codegen/xai-grok-workspace/src/permission/prompter.rs @@ -565,6 +565,22 @@ impl AcpPrompter { } } + /// Request `_meta`: bash selection scope, or protected-edit description for Edit. + fn permission_request_meta( + &self, + access: &AccessKind, + protected_edit: Option, + ) -> Option { + if let Some(bash) = self.bash_selection_meta(access) { + return Some(bash); + } + let reason = protected_edit?; + let payload = crate::permission::ProtectedEditPermission::from_reason(reason); + serde_json::to_value(payload) + .ok() + .and_then(|v| v.as_object().cloned()) + } + /// Build the per-access-kind option map WITHOUT the /// "enable always-approve mode" prepend. Kept as a separate inner /// fn so `build_options` can wrap the result with one prepend call @@ -719,6 +735,7 @@ impl AcpPrompter { &self, access: &AccessKind, tool_call_update: &acp::ToolCallUpdate, + protected_edit: Option, ) -> PromptOutcome { let tool_name = tool_name_for_access(access); // events.jsonl: `PermissionRequested` at prompt-start. The `Instant` @@ -753,7 +770,7 @@ impl AcpPrompter { tool_call_update.clone(), permission_options.values().cloned().collect(), ) - .meta(self.bash_selection_meta(access)); + .meta(self.permission_request_meta(access, protected_edit)); match self.gateway.request_permission(req).await { Ok(resp) => match resp.outcome { acp::RequestPermissionOutcome::Cancelled => PromptOutcome::Cancelled, @@ -1625,7 +1642,7 @@ mod tests { acp::ToolCallUpdateFields::default(), ); - let outcome = prompter.request(&access, &tool_call_update).await; + let outcome = prompter.request(&access, &tool_call_update, None).await; assert!( matches!(outcome, PromptOutcome::Error(_)), "dropped gateway receiver should yield PromptOutcome::Error" @@ -1675,7 +1692,7 @@ mod tests { acp::ToolCallId::new(Arc::from("tc-2")), acp::ToolCallUpdateFields::default(), ); - let outcome = prompter.request(&access, &tool_call_update).await; + let outcome = prompter.request(&access, &tool_call_update, None).await; assert!(matches!(outcome, PromptOutcome::Error(_))); } } diff --git a/crates/codegen/xai-grok-workspace/src/permission/shell_access.rs b/crates/codegen/xai-grok-workspace/src/permission/shell_access.rs index 951ae1d..45799d1 100644 --- a/crates/codegen/xai-grok-workspace/src/permission/shell_access.rs +++ b/crates/codegen/xai-grok-workspace/src/permission/shell_access.rs @@ -319,26 +319,109 @@ pub(crate) fn is_safe_write_sink(path: &str) -> bool { matches!(path, "/dev/null" | "/dev/stdout" | "/dev/stderr") } -/// Whether an already-resolved direct edit target needs explicit confirmation. +/// Why acceptEdits must still prompt for this edit target. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProtectedEditReason { + HookRoot, + GitHooks, + Ssh, + StartupFile, + Etc, + GrokConfig, + ClaudeSettings, + CursorHooks, + /// Fail-closed / unclassified sensitive path; no user copy yet. + Sensitive, +} + +impl ProtectedEditReason { + pub fn kind(self) -> &'static str { + match self { + Self::HookRoot => "hook_root", + Self::GitHooks => "git_hooks", + Self::Ssh => "ssh", + Self::StartupFile => "startup_file", + Self::Etc => "etc", + Self::GrokConfig => "grok_config", + Self::ClaudeSettings => "claude_settings", + Self::CursorHooks => "cursor_hooks", + Self::Sensitive => "sensitive", + } + } + + pub fn description(self) -> Option<&'static str> { + match self { + Self::HookRoot => Some( + "Note: This edit contains changes to hooks, which can be executed as code on later sessions without a separate execution approval.", + ), + Self::GitHooks => Some( + "Note: This edit contains changes to Git hooks, which can run automatically on commit, push, or other Git actions without a separate execution approval.", + ), + Self::Ssh => Some( + "Note: This edit contains changes under `.ssh`, which can affect credentials and authentication for future sessions.", + ), + Self::StartupFile => Some( + "Note: This edit contains changes to a shell startup file, which can run automatically in future terminals without a separate execution approval.", + ), + Self::Etc => Some( + "Note: This edit contains changes under `/etc`, which is system configuration and can affect this machine beyond the current project.", + ), + Self::GrokConfig => Some( + "Note: This edit contains changes to Grok config, which can alter permissions, tools, and other behavior in later sessions.", + ), + Self::ClaudeSettings => Some( + "Note: This edit contains changes to Claude-compatible settings, which can install hooks or change permission mode without a separate execution approval.", + ), + Self::CursorHooks => Some( + "Note: This edit contains changes to Cursor hooks, which can run automatically in later sessions without a separate execution approval.", + ), + Self::Sensitive => None, + } + } +} + +/// ACP `_meta` payload for protected-edit prompts (pager reads this for description). +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProtectedEditPermission { + pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +impl ProtectedEditPermission { + pub fn from_reason(reason: ProtectedEditReason) -> Self { + Self { + kind: reason.kind().to_owned(), + description: reason.description().map(str::to_owned), + } + } +} + +/// Whether an already-resolved direct edit target needs confirmation, and why. /// /// The caller uses the edit tools' shared model-path resolver first. This helper /// preserves its uncollapsed components for physical symlink + `..` resolution, /// while checking a separate lexical normalization for traversal aliases. -pub(crate) fn edit_target_requires_prompt(path: &Path) -> bool { +pub(crate) fn edit_target_protection(path: &Path) -> Option { if !path.is_absolute() { - return true; + return Some(ProtectedEditReason::Sensitive); } let lexical = xai_grok_paths::normalize_lexically(path); - if protected_edit_path(&lexical) { - return true; + if let Some(reason) = protected_edit_reason(&lexical) { + return Some(reason); } let Some(resolved) = resolve_following_symlinks(path, 0) else { - return true; + return Some(ProtectedEditReason::Sensitive); }; - protected_edit_path(&resolved) || resolved_path_is_within_root(&resolved, Path::new("/etc")) + if let Some(reason) = protected_edit_reason(&resolved) { + return Some(reason); + } + resolved_path_is_within_root(&resolved, Path::new("/etc")) + .then_some(ProtectedEditReason::Sensitive) } -fn protected_edit_path(path: &Path) -> bool { +fn protected_edit_reason(path: &Path) -> Option { let components: Vec = path .components() .filter_map(|component| match component { @@ -368,12 +451,49 @@ fn protected_edit_path(path: &Path) -> bool { ".xprofile", ]; - STARTUP_FILES.contains(&file) - || protected_git_hooks_path(&string_components) - || string_components.contains(&".ssh") - || string_components.ends_with(&[".grok", "config.toml"]) - || path == Path::new("/etc") - || path.starts_with(Path::new("/etc")) + if protected_grok_hook_root(path, &string_components) { + return Some(ProtectedEditReason::HookRoot); + } + if string_components.ends_with(&[".claude", "settings.json"]) + || string_components.ends_with(&[".claude", "settings.local.json"]) + { + return Some(ProtectedEditReason::ClaudeSettings); + } + if string_components.ends_with(&[".cursor", "hooks.json"]) { + return Some(ProtectedEditReason::CursorHooks); + } + if protected_git_hooks_path(&string_components) { + return Some(ProtectedEditReason::GitHooks); + } + if string_components.contains(&".ssh") { + return Some(ProtectedEditReason::Ssh); + } + if STARTUP_FILES.contains(&file) { + return Some(ProtectedEditReason::StartupFile); + } + if string_components.ends_with(&[".grok", "config.toml"]) { + return Some(ProtectedEditReason::GrokConfig); + } + if path == Path::new("/etc") || path.starts_with(Path::new("/etc")) { + return Some(ProtectedEditReason::Etc); + } + None +} + +fn path_is_under_user_grok_hook_root(path: &Path, grok_home: &Path) -> bool { + path.starts_with(grok_home.join("hooks")) || path == grok_home.join("hooks-paths") +} + +fn protected_grok_hook_root(path: &Path, components: &[&str]) -> bool { + components.windows(2).any(|pair| pair == [".grok", "hooks"]) + || components.ends_with(&[".grok", "hooks-paths"]) + || xai_grok_config::user_grok_home().is_some_and(|grok_home| { + let lexical_home = xai_grok_paths::normalize_lexically(&grok_home); + path_is_under_user_grok_hook_root(path, &lexical_home) + || resolve_following_symlinks(&lexical_home, 0).is_some_and(|resolved_home| { + path_is_under_user_grok_hook_root(path, &resolved_home) + }) + }) } fn protected_git_hooks_path(components: &[&str]) -> bool { @@ -1251,7 +1371,7 @@ mod tests { "/work/subdir/../.git/hooks/pre-commit", ] { assert!( - edit_target_requires_prompt(Path::new(path)), + edit_target_protection(Path::new(path)).is_some(), "protected edit target must prompt: {path}" ); } @@ -1260,7 +1380,7 @@ mod tests { "/work/project/.grok/config.toml/backup", ] { assert!( - !edit_target_requires_prompt(Path::new(path)), + edit_target_protection(Path::new(path)).is_none(), "ordinary edit target should not prompt: {path}" ); } @@ -1275,7 +1395,7 @@ mod tests { "/work/subdir/../.git/modules/foo/hooks/pre-commit", ] { assert!( - edit_target_requires_prompt(Path::new(path)), + edit_target_protection(Path::new(path)).is_some(), "submodule hook target must prompt: {path}" ); } @@ -1287,12 +1407,110 @@ mod tests { "/work/src/modules/foo/hooks/pre-commit", ] { assert!( - !edit_target_requires_prompt(Path::new(path)), + edit_target_protection(Path::new(path)).is_none(), "non-hook control must not prompt: {path}" ); } } + #[test] + fn edit_target_protection_classifies_reasons() { + let cases = [ + ( + "/home/user/.grok/hooks/evil.json", + ProtectedEditReason::HookRoot, + ), + ("/work/.git/hooks/pre-commit", ProtectedEditReason::GitHooks), + ("/home/user/.ssh/id_rsa", ProtectedEditReason::Ssh), + ("/home/user/.zshrc", ProtectedEditReason::StartupFile), + ("/etc/hosts", ProtectedEditReason::Etc), + ( + "/home/user/.grok/config.toml", + ProtectedEditReason::GrokConfig, + ), + ( + "/home/user/.claude/settings.json", + ProtectedEditReason::ClaudeSettings, + ), + ( + "/home/user/.cursor/hooks.json", + ProtectedEditReason::CursorHooks, + ), + ]; + for (path, reason) in cases { + assert_eq!( + edit_target_protection(Path::new(path)), + Some(reason), + "{path}" + ); + assert!(reason.description().is_some(), "{path}"); + } + assert_eq!( + edit_target_protection(Path::new("/home/user/project/src/main.rs")), + None + ); + assert!(ProtectedEditReason::Sensitive.description().is_none()); + } + + #[test] + fn sensitive_edit_targets_include_hook_roots() { + for path in [ + "/home/user/.grok/hooks/evil.json", + "/home/user/.grok/hooks/nested/deep.json", + "/home/user/.grok/hooks-paths", + "/home/user/.claude/settings.json", + "/home/user/.claude/settings.local.json", + "/home/user/.cursor/hooks.json", + "/work/project/.grok/hooks/local.json", + "/work/project/.grok/hooks-paths", + ] { + assert!( + edit_target_protection(Path::new(path)).is_some(), + "hook root edit target must prompt: {path}" + ); + } + for path in [ + "/home/user/.grok/hooks-disabled/note.json", + "/home/user/.grok/hooks-evil/note.json", + "/home/user/project/src/hooks.json", + "/home/user/.claude/other.json", + "/home/user/.cursor/settings.json", + ] { + assert!( + edit_target_protection(Path::new(path)).is_none(), + "ordinary edit target should not prompt: {path}" + ); + } + } + + #[test] + fn path_is_under_user_grok_hook_root_matches_relocated_home() { + let home = Path::new("/custom/grok-home"); + for path in [ + "/custom/grok-home/hooks/x.json", + "/custom/grok-home/hooks/nested/deep.json", + "/custom/grok-home/hooks", + "/custom/grok-home/hooks-paths", + ] { + assert!( + path_is_under_user_grok_hook_root(Path::new(path), home), + "must match under custom grok home: {path}" + ); + } + for path in [ + "/custom/grok-home/hooks-disabled/note.json", + "/custom/grok-home/hooks-evil/note.json", + "/custom/grok-home/config.toml", + "/custom/other/hooks/x.json", + "/custom/grok-home-extra/hooks/x.json", + ] { + assert!( + !path_is_under_user_grok_hook_root(Path::new(path), home), + "must not match outside hook roots: {path}" + ); + } + } + #[test] #[cfg(unix)] fn sensitive_edit_targets_follow_symlinks() { @@ -1314,14 +1532,19 @@ mod tests { ws.path().join("module-hooks-link"), ) .unwrap(); + let grok_hook = outside.path().join(".grok/hooks/evil.json"); + std::fs::create_dir_all(grok_hook.parent().unwrap()).unwrap(); + std::fs::write(&grok_hook, b"{}").unwrap(); + symlink(&grok_hook, ws.path().join("grok-hook-link")).unwrap(); for path in [ ws.path().join("file-link"), ws.path().join("hooks-link/new-hook"), ws.path().join("module-hooks-link/new-hook"), + ws.path().join("grok-hook-link"), ] { assert!( - edit_target_requires_prompt(&path), + edit_target_protection(&path).is_some(), "symlinked protected edit target must prompt: {}", path.display() ); @@ -1344,7 +1567,7 @@ mod tests { #[test] #[cfg(target_os = "macos")] fn private_etc_alias_requires_prompt() { - assert!(edit_target_requires_prompt(Path::new("/private/etc/hosts"))); + assert!(edit_target_protection(Path::new("/private/etc/hosts")).is_some()); } #[test] diff --git a/crates/codegen/xai-grok-workspace/src/rpc_envelope.rs b/crates/codegen/xai-grok-workspace/src/rpc_envelope.rs index 7546a17..96f2716 100644 --- a/crates/codegen/xai-grok-workspace/src/rpc_envelope.rs +++ b/crates/codegen/xai-grok-workspace/src/rpc_envelope.rs @@ -3,16 +3,12 @@ //! //! `error_code` uses a non-wildcard match so the compiler enforces //! coverage of new `WorkspaceError` variants. - -pub use xai_grok_workspace_types::rpc::{RpcEnvelope, RpcError}; - use crate::error::WorkspaceError; - +pub use xai_grok_workspace_types::rpc::{RpcEnvelope, RpcError}; /// Build an error envelope from a `WorkspaceError`. pub fn envelope_err(error: &WorkspaceError) -> RpcEnvelope { RpcEnvelope::err_parts(error_code(error), error.to_string()) } - /// Map a `WorkspaceError` to its wire code string. /// /// Uses an exhaustive match with no wildcard -- the compiler will @@ -34,12 +30,11 @@ pub fn error_code(err: &WorkspaceError) -> &'static str { WorkspaceError::InvalidHunkAction(_) => "invalid_hunk_action", WorkspaceError::HunkActionFailed(_) => "hunk_action_failed", WorkspaceError::HubError(_) => "hub_error", - WorkspaceError::DeployError { kind, .. } => kind.wire_code(), + WorkspaceError::ExportGithub { kind, .. } => kind.wire_code(), WorkspaceError::ShuttingDown => "shutting_down", WorkspaceError::ToolsetExternallyOwned(_) => "toolset_externally_owned", } } - /// Map a wire [`RpcError`] back to a [`WorkspaceError`]. /// /// Known codes are mapped to their specific variants. Unknown codes @@ -59,9 +54,9 @@ pub fn error_code(err: &WorkspaceError) -> &'static str { /// prefix (e.g. `"capability_widening: ..."`). pub fn rpc_error_to_workspace(err: RpcError) -> WorkspaceError { if let Some(kind) = - xai_grok_workspace_types::rpc::deploy::DeployError::from_wire_code(&err.code) + xai_grok_workspace_types::rpc::export_github::ExportGithubError::from_wire_code(&err.code) { - return WorkspaceError::DeployError { + return WorkspaceError::ExportGithub { kind, message: err.message, }; @@ -92,12 +87,10 @@ pub fn rpc_error_to_workspace(err: RpcError) -> WorkspaceError { } } } - #[cfg(test)] mod tests { use super::*; use crate::capability::CapabilityMode; - /// Verify round-trip fidelity for every `WorkspaceError` variant. #[test] fn error_code_round_trip_all_variants() { @@ -125,32 +118,15 @@ mod tests { WorkspaceError::ShuttingDown, WorkspaceError::ToolsetExternallyOwned("s".into()), ]; - variants.extend( - xai_grok_workspace_types::rpc::deploy::DeployError::ALL - .into_iter() - .map(|kind| WorkspaceError::DeployError { - kind, - message: "deploy".into(), - }), - ); - for err in &variants { let code = error_code(err); assert!(!code.is_empty(), "code must not be empty for {err:?}"); - - // Round-trip through RpcError let rpc_err = RpcError { code: code.to_owned(), message: err.to_string(), }; let recovered = rpc_error_to_workspace(rpc_err); - // The recovered error's code should match the original code let recovered_code = error_code(&recovered); - - // Structured variants (CapabilityWidening, Unauthorized, - // MaxDepthExceeded) lose their fields on the wire and - // degrade to HubError, which is the expected behavior. - // Their error messages are preserved in the HubError string. match err { WorkspaceError::CapabilityWidening { .. } => { assert_eq!(recovered_code, "hub_error"); @@ -185,9 +161,26 @@ mod tests { } } } - /// Verify unknown codes degrade to HubError. #[test] + fn export_github_codes_round_trip_typed() { + for kind in xai_grok_workspace_types::rpc::export_github::ExportGithubError::ALL { + let err = WorkspaceError::ExportGithub { + kind, + message: "boom".into(), + }; + let rpc_err = RpcError { + code: error_code(&err).into(), + message: "boom".into(), + }; + let recovered = rpc_error_to_workspace(rpc_err); + assert!( + matches!(recovered, WorkspaceError::ExportGithub { kind: k, .. } if k == kind), + "lost typed export error for {kind:?}: {recovered:?}" + ); + } + } + #[test] fn unknown_code_degrades_to_hub_error() { let rpc_err = RpcError { code: "future_new_variant".into(), @@ -198,7 +191,6 @@ mod tests { let msg = recovered.to_string(); assert!(msg.contains("future_new_variant")); } - /// Verify serde round-trip of RpcEnvelope. #[test] fn envelope_serde_round_trip_ok() { @@ -210,7 +202,6 @@ mod tests { Err(e) => panic!("expected Ok, got {e:?}"), } } - /// Verify serde round-trip of RpcEnvelope error, through the /// `WorkspaceError` mapping in both directions. #[test] diff --git a/crates/codegen/xai-grok-workspace/src/workspace_ops.rs b/crates/codegen/xai-grok-workspace/src/workspace_ops.rs index 3357ee2..6c32022 100644 --- a/crates/codegen/xai-grok-workspace/src/workspace_ops.rs +++ b/crates/codegen/xai-grok-workspace/src/workspace_ops.rs @@ -36,6 +36,7 @@ pub use xai_grok_workspace_types::rpc::code_nav::{ CodeFindDefinitionsReq, CodeFindReferencesReq, CodeGotoDefinitionReq, CodeGotoReferencesReq, CodeIndexStats, CodeIndexStatusReq, CodeIndexStatusResponse, CodeNavLocation, CodeNavResponse, }; +pub use xai_grok_workspace_types::rpc::export_github::ExportGithubReq; pub use xai_grok_workspace_types::rpc::fs::{ ClientFsListNode, ClientFsListReq, ClientFsListRes, ClientFsReadFileReq, ClientFsReadFileRes, ClientFsStatReq, ClientFsStatRes, GetFileEntry, GetFileResult, GetFilesReq, GetFilesRes, @@ -98,6 +99,37 @@ pub trait WorkspaceOp: WorkspaceRpc + DeserializeOwned + Send + Sync { pub struct PrepareWorktreeFromWorktreeReq { pub inner: crate::worktree::CreateWorktreeFromWorktreeRequest, } +#[async_trait] +impl WorkspaceOp for ExportGithubReq { + async fn execute( + &self, + ws: &WorkspaceHandle, + _session_id: Option<&str>, + ) -> WorkspaceResult { + if std::path::Path::new(&self.project_dir).is_absolute() { + return Err(WorkspaceError::HubError( + "project_dir must be relative to the workspace root".into(), + )); + } + let canonical_root = ws.canonical_root().await?; + let project_dir = ws + .resolve_service_path(&self.project_dir, &canonical_root) + .await?; + crate::export_github::run_export(crate::export_github::ExportGithubParams { + project_dir: &project_dir, + repo_full_name: self.repo_full_name.as_deref(), + remote_url_base: "https://github.com", + web_url_base: "https://github.com", + branch: self.branch.as_deref(), + commit_message: self.commit_message.as_deref(), + }) + .await + .map_err(|failure| WorkspaceError::ExportGithub { + kind: failure.kind, + message: failure.message, + }) + } +} /// Get all rewind points for the session. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GetRewindPointsReq { @@ -856,10 +888,8 @@ impl WorkspaceOp for ContentSearchRequest { ws.run_content_search(cwd, context_id, params).await } } -/// Convert the heavy `HookRegistry` to its wire mirror via a serde round-trip. -/// The registry's `hooks` map is private, so reconstructing field-by-field -/// isn't possible; the round-trip is faithful because the wire type mirrors the -/// serde shape exactly (the compiled `matcher` is `#[serde(skip)]` either way). +/// Convert `HookRegistry` to its wire mirror. The `hooks` map is private, so a +/// serde round-trip stands in for field-by-field construction. fn hook_registry_to_wire( registry: &xai_grok_hooks::discovery::HookRegistry, ) -> WorkspaceResult { @@ -867,13 +897,37 @@ fn hook_registry_to_wire( serde_json::to_value(registry).map_err(|e| WorkspaceError::HubError(e.to_string()))?; serde_json::from_value(value).map_err(|e| WorkspaceError::HubError(e.to_string())) } -/// Inverse of [`hook_registry_to_wire`]. Rebuilds compiled matchers via -/// [`HookRegistry::recompile_matchers`] so invalid patterns fail closed -/// (match nothing) rather than widening to match-all after the wire hop. +/// Inverse of [`hook_registry_to_wire`]. Unknown event keys (a newer peer) are +/// dropped so one can't fail the whole decode, and matchers are recompiled +/// fail-closed after the hop. fn wire_to_hook_registry( wire: &HookRegistryWire, ) -> WorkspaceResult { - let value = serde_json::to_value(wire).map_err(|e| WorkspaceError::HubError(e.to_string()))?; + let dropped: Vec<&str> = wire + .hooks + .keys() + .filter_map(|event| match event { + HookEventNameWire::Unknown(name) => Some(name.as_str()), + _ => None, + }) + .collect(); + if !dropped.is_empty() { + tracing::debug!( + dropped_count = dropped.len(), + dropped_events = ?dropped, + "dropping unknown hook event keys from peer wire registry" + ); + } + let known = HookRegistryWire { + hooks: wire + .hooks + .iter() + .filter(|(event, _)| !matches!(event, HookEventNameWire::Unknown(_))) + .map(|(event, specs)| (event.clone(), specs.clone())) + .collect(), + }; + let value = + serde_json::to_value(&known).map_err(|e| WorkspaceError::HubError(e.to_string()))?; let mut registry: xai_grok_hooks::discovery::HookRegistry = serde_json::from_value(value).map_err(|e| WorkspaceError::HubError(e.to_string()))?; registry.recompile_matchers(); @@ -1763,6 +1817,7 @@ mod tests { timeout_ms: 5000, source_dir: std::path::PathBuf::from("/home/u/.grok/hooks"), extra_env: std::collections::HashMap::from([("FOO".to_string(), "bar".to_string())]), + layer: xai_grok_hooks::config::HookProvenance::File, }; let mut registry = xai_grok_hooks::discovery::HookRegistry::default(); registry.append_specs(vec![spec]); @@ -1857,6 +1912,7 @@ mod tests { timeout_ms, source_dir, extra_env, + layer, } = spec; let event = serde_json::from_value(serde_json::to_value(event).unwrap()).unwrap(); HookSpecWire { @@ -1872,6 +1928,7 @@ mod tests { timeout_ms, source_dir, extra_env, + layer: layer.as_str().to_string(), } } let spec = HookSpec { @@ -1888,6 +1945,7 @@ mod tests { timeout_ms: 5000, source_dir: std::path::PathBuf::from("/home/u/.grok/hooks"), extra_env: std::collections::HashMap::from([("FOO".to_string(), "bar".to_string())]), + layer: xai_grok_hooks::config::HookProvenance::Managed, }; assert_eq!( serde_json::to_value(&spec).unwrap(),