Synced from monorepo
Changes: - Classify clipboard delivery confidence - Add durable session update append - Scope the xAI session bearer to first-party memory embedding endpoints - Persist subagent outputs to disk and bound long-lived agent state - Add MiniSweAgent:bash for mini-swe-agent parity - Revert taking local sessions off the persistent shell - Contextual tip recommending grok wrap on SSH sessions - Voice STT bearer from model BYOK env_key/api_key - Define exact website policies for sandbox - Gate unsafe shell environments - Shared pin hoist; single require_sha gate for marketplace plugins - Server-signed is-managed claim (closes sidecar-removal downgrade) - Optional require_sha pin for remote plugin installs - Show session title and last exchange in the exit resume hint - Gate shell output redirects - Warn when fail_closed is present but not a boolean - Add canonical text editing core (ratatui-textarea) - Keep execution state out of goal scratch - Add acknowledged persistence primitives - Inherit child network restrictions in sandbox - Fail closed when hook matchers fail to recompile - Add MCP setup preferences for plugin MCPs - Gate sourced shell scripts - Gate file-typed project hooks - grok wrap: restore terminal modes on child death - Harden owner-only permissions on auth and MCP credentials - Create crash dump files with owner-only permissions - Write the agent_id cache owner-only (0600) - SessionMetrics mode skips Mixpanel profile sync - Dashboard: slim live-tail peek - Yank full queued prompt text, not (+N lines) - Defeat clock-rollback on the signed managed-config cache - Stop early session/cancel from overtaking the prompt and wedging the turn slot - Self-heal a diverged agent entrypoint on startup - Add matched inference expectations in test-support - Add AuthSingleFlight cancel/successor gap tests - Remove consumer from external OTEL allowlist and pin scrub coverage - Enable /copy in minimal mode - Surface capacity and API-key detail on 429 errors - Single-flight interactive auth - Fix PageUp/PageDown skipping lines behind sticky prompt header
This commit is contained in:
parent
8adf9013a0
commit
98c3b2438a
225 changed files with 18836 additions and 7156 deletions
|
|
@ -255,6 +255,10 @@ pub async fn track(event_name: &str, request_id: &str, ctx: &UserContext, mut me
|
|||
}
|
||||
|
||||
/// Sync the user's Mixpanel profile once per init. Fire-and-forget.
|
||||
///
|
||||
/// Only runs in [`TelemetryMode::Enabled`]. SessionMetrics mode may emit
|
||||
/// lifecycle events via [`track`], but must not write Mixpanel people
|
||||
/// profiles (`engage`).
|
||||
pub fn sync_profile() {
|
||||
let lock = TELEMETRY_CLIENT.get_or_init(|| Mutex::new(None));
|
||||
let client = {
|
||||
|
|
@ -265,6 +269,12 @@ pub fn sync_profile() {
|
|||
}
|
||||
};
|
||||
|
||||
// The single profile-sync gate: reads the installed client's mode, so every
|
||||
// caller (and any init race) resolves against what was actually installed.
|
||||
if !client.mode.is_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(mixpanel) = client.mixpanel.clone() else {
|
||||
return;
|
||||
};
|
||||
|
|
@ -396,6 +406,56 @@ mod tests {
|
|||
assert_eq!(event_value("grok-workspace-turn"), "turn");
|
||||
}
|
||||
|
||||
/// SessionMetrics must not attempt Mixpanel profile engage — sync_profile
|
||||
/// is a no-op unless mode is fully Enabled.
|
||||
#[test]
|
||||
fn sync_profile_is_noop_in_session_metrics_mode() {
|
||||
// No tokio runtime here BY DESIGN: if the gate wrongly falls through,
|
||||
// sync_profile's tokio::spawn panics and fails this test. Converting
|
||||
// this to #[tokio::test] would silently turn it into theater.
|
||||
assert!(
|
||||
tokio::runtime::Handle::try_current().is_err(),
|
||||
"this test must run without a tokio runtime"
|
||||
);
|
||||
// Clear the global client even if an assert below panics.
|
||||
struct ClearClient;
|
||||
impl Drop for ClearClient {
|
||||
fn drop(&mut self) {
|
||||
let lock = TELEMETRY_CLIENT.get_or_init(|| Mutex::new(None));
|
||||
*lock.lock().unwrap_or_else(|err| err.into_inner()) = None;
|
||||
}
|
||||
}
|
||||
let _clear = ClearClient;
|
||||
|
||||
// Mixpanel configured, but no events endpoint: the global must never
|
||||
// carry a live funnel out of this test.
|
||||
let cfg = TelemetryConfig {
|
||||
mixpanel_enabled: true,
|
||||
mixpanel_token: Some("test-token".into()),
|
||||
events_url: None,
|
||||
events_api_key: None,
|
||||
..TelemetryConfig::default()
|
||||
};
|
||||
init(
|
||||
cfg,
|
||||
TelemetryMode::SessionMetrics,
|
||||
Some("user-1".into()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"0.0.0-test".into(),
|
||||
None,
|
||||
reqwest::Client::new(),
|
||||
);
|
||||
// Explicit call must no-op too (init already invoked it once).
|
||||
sync_profile();
|
||||
assert!(
|
||||
is_session_metrics_enabled(),
|
||||
"client must be live for session metrics"
|
||||
);
|
||||
assert!(!is_enabled(), "product analytics must stay off");
|
||||
}
|
||||
|
||||
/// Names without a known emitter prefix pass through unchanged (preserves
|
||||
/// the old `unwrap_or(event_name)` fallback).
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@ pub enum ContextualTipKind {
|
|||
SmallScreen,
|
||||
/// Double-click fold/nav path → tip to enable Word select in settings.
|
||||
WordSelect,
|
||||
/// SSH session without `grok wrap` → tip to wrap the ssh command locally.
|
||||
SshWrap,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Copy)]
|
||||
|
|
@ -1303,10 +1305,15 @@ pub struct ClipboardCopy {
|
|||
pub data_control: bool,
|
||||
pub tmux_ok: bool,
|
||||
pub osc52_ok: bool,
|
||||
/// `native_ok || tmux_ok || osc52_ok` where tmux/osc52 are real leg outcomes.
|
||||
/// Evidence classification: `confirmed` | `unverified` | `failed`.
|
||||
pub delivery: &'static str,
|
||||
/// An explicit `grok wrap` OSC 52 sink was active.
|
||||
pub osc52_sink: bool,
|
||||
/// The process was inside a container without a display server.
|
||||
pub container_no_display: bool,
|
||||
/// Historical boolean projection: true unless `delivery == failed`.
|
||||
pub reported_success: bool,
|
||||
/// UX toast branch (route-shaped, not leg-shaped): `copied` | `copied_tmux` |
|
||||
/// `copied_osc_remote` | `copied_osc_container` | `failed`.
|
||||
/// Exact UX toast branch selected by the environment policy.
|
||||
pub toast_kind: &'static str,
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
|
@ -1814,6 +1821,64 @@ telemetry_event!(
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn terminal_telemetry_fixture() -> TerminalTelemetry {
|
||||
TerminalTelemetry {
|
||||
brand: "Unknown".into(),
|
||||
multiplexer: "none".into(),
|
||||
is_ssh: true,
|
||||
is_byobu: false,
|
||||
term_var: "xterm-256color".into(),
|
||||
tmux_version: "".into(),
|
||||
xtversion: "".into(),
|
||||
host_os: "linux".into(),
|
||||
display_server: "unknown".into(),
|
||||
modifier_cmd_fate: "unknown".into(),
|
||||
modifier_opt_fate: "unknown".into(),
|
||||
enter_modifier_fate: "unknown".into(),
|
||||
hyperlink_osc8: "unknown".into(),
|
||||
hyperlink_skip_reason: "none".into(),
|
||||
clipboard_route: "native+osc52".into(),
|
||||
clipboard_native_tool: "arboard".into(),
|
||||
clipboard_data_control: "n/a".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_copy_serialization_preserves_boolean_and_adds_delivery_evidence() {
|
||||
for delivery in ["confirmed", "unverified", "failed"] {
|
||||
let value = serde_json::to_value(ClipboardCopy {
|
||||
terminal: terminal_telemetry_fixture(),
|
||||
source: "copy_text",
|
||||
text_len: 12,
|
||||
route_native: true,
|
||||
route_tmux: false,
|
||||
route_osc52: true,
|
||||
route_label: "native+osc52".into(),
|
||||
cli_tools_tried: String::new(),
|
||||
cli_ok_tools: String::new(),
|
||||
cli_ok: false,
|
||||
arboard_ok: false,
|
||||
data_control: false,
|
||||
tmux_ok: false,
|
||||
osc52_ok: true,
|
||||
delivery,
|
||||
osc52_sink: false,
|
||||
container_no_display: false,
|
||||
reported_success: delivery != "failed",
|
||||
toast_kind: "unverified_osc_remote",
|
||||
duration_ms: 1,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(value["delivery"], serde_json::json!(delivery));
|
||||
assert_eq!(
|
||||
value["reported_success"],
|
||||
serde_json::Value::Bool(delivery != "failed")
|
||||
);
|
||||
assert_eq!(value["osc52_sink"], serde_json::json!(false));
|
||||
assert_eq!(value["container_no_display"], serde_json::json!(false));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_auth_name_and_shape() {
|
||||
assert_eq!(ManualAuth::NAME, "manual_auth");
|
||||
|
|
|
|||
|
|
@ -702,6 +702,7 @@ fn contextual_tip_kind_label(t: events::ContextualTipKind) -> &'static str {
|
|||
events::ContextualTipKind::SendNow => "send_now",
|
||||
events::ContextualTipKind::SmallScreen => "small_screen",
|
||||
events::ContextualTipKind::WordSelect => "word_select",
|
||||
events::ContextualTipKind::SshWrap => "ssh_wrap",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -718,6 +718,8 @@ fn contextual_tip_maps_every_tip_and_action() {
|
|||
(K::SmallScreen, A::Accepted, "small_screen", "accepted"),
|
||||
(K::WordSelect, A::Shown, "word_select", "shown"),
|
||||
(K::WordSelect, A::Accepted, "word_select", "accepted"),
|
||||
(K::SshWrap, A::Shown, "ssh_wrap", "shown"),
|
||||
(K::SshWrap, A::Accepted, "ssh_wrap", "accepted"),
|
||||
];
|
||||
for (tip, action, tip_label, action_label) in cases {
|
||||
let stream = build(gates_off());
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ fn load_or_compute_agent_id() -> String {
|
|||
if let Ok(cached) = std::fs::read_to_string(&cache_path) {
|
||||
let cached = cached.trim();
|
||||
if !cached.is_empty() {
|
||||
tighten_agent_id_cache_perms(&cache_path);
|
||||
return cached.to_string();
|
||||
}
|
||||
}
|
||||
|
|
@ -60,12 +61,81 @@ fn load_or_compute_agent_id() -> String {
|
|||
};
|
||||
let id = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, machine_hash.as_bytes()).to_string();
|
||||
|
||||
// Save to cache file (best effort, ignore errors)
|
||||
let _ = std::fs::write(&cache_path, &id);
|
||||
// Save to cache file with owner-only perms (best effort).
|
||||
let _ = write_agent_id_cache(&cache_path, &id);
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
/// Write `$GROK_HOME/agent_id` as owner-read/write only (Unix 0o600) — it is a
|
||||
/// stable device identifier and must not be world-readable. Atomic temp+rename,
|
||||
/// so overwriting a loose-perms cache from an older build never leaves the id
|
||||
/// in a world-readable file.
|
||||
fn write_agent_id_cache(path: &std::path::Path, id: &str) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
xai_grok_config::fs_atomic::write_atomically(path, id, Some(0o600))
|
||||
}
|
||||
|
||||
/// Best-effort 0o600 on an existing cache: tightens caches written world-readable
|
||||
/// by older builds. No-op off Unix or on error (the id itself still loads).
|
||||
fn tighten_agent_id_cache_perms(path: &std::path::Path) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = path;
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fn mode(path: &std::path::Path) -> u32 {
|
||||
std::fs::metadata(path).expect("meta").permissions().mode() & 0o777
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_id_cache_written_owner_only() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("agent_id");
|
||||
write_agent_id_cache(&path, "test-agent-id-value").expect("write");
|
||||
assert_eq!(mode(&path), 0o600, "agent_id cache must be 0o600");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&path).expect("read").trim(),
|
||||
"test-agent-id-value"
|
||||
);
|
||||
}
|
||||
|
||||
/// Overwriting an existing loose-perms cache (e.g. an old build's empty or
|
||||
/// torn write) must still land 0600 — mode-at-create alone would keep 0644.
|
||||
#[test]
|
||||
fn rewrite_over_loose_perms_cache_lands_owner_only() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("agent_id");
|
||||
std::fs::write(&path, "").expect("write");
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod");
|
||||
write_agent_id_cache(&path, "fresh-id").expect("rewrite");
|
||||
assert_eq!(mode(&path), 0o600, "rewrite must not inherit loose perms");
|
||||
assert_eq!(std::fs::read_to_string(&path).expect("read"), "fresh-id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_world_readable_cache_is_tightened() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("agent_id");
|
||||
std::fs::write(&path, "legacy-id").expect("write");
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod");
|
||||
tighten_agent_id_cache_perms(&path);
|
||||
assert_eq!(mode(&path), 0o600, "legacy cache must be tightened on read");
|
||||
assert_eq!(std::fs::read_to_string(&path).expect("read"), "legacy-id");
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when workspace marker env vars (`XAI_ROOT` and `XAI_USER`) are set.
|
||||
///
|
||||
/// Used as a coarse local gate for features that require a full workspace
|
||||
|
|
|
|||
|
|
@ -99,10 +99,6 @@ pub(super) static ALLOWED_STRING_KEYS: &[&str] = &[
|
|||
"status",
|
||||
"action",
|
||||
"auth_method",
|
||||
// auth 401 attribution: fixed enum-ish consumer labels only
|
||||
// (e.g. "OaiCompatClient.chat_completions_stream"); never user content.
|
||||
// Key suffix fields stay denied — they are token fingerprints.
|
||||
"consumer",
|
||||
"to_mode",
|
||||
"trigger",
|
||||
"survey_type",
|
||||
|
|
@ -458,7 +454,6 @@ mod tests {
|
|||
"status",
|
||||
"action",
|
||||
"auth_method",
|
||||
"consumer",
|
||||
"to_mode",
|
||||
"trigger",
|
||||
"survey_type",
|
||||
|
|
@ -502,7 +497,7 @@ mod tests {
|
|||
assert_eq!(
|
||||
ALLOWED_STRING_KEYS, expected,
|
||||
"ALLOWED_STRING_KEYS changed: adding a key exports a new field — confirm it carries no \
|
||||
user content, then update this pin."
|
||||
user content and get telemetry-owner review, then update this pin."
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -590,4 +585,52 @@ mod tests {
|
|||
"secret in allowlisted value not scrubbed: {blob}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlisted_path_values_are_still_home_scrubbed() {
|
||||
// Path keys are allowlisted so the field exports, but home/username
|
||||
// segments must still collapse — allowlist is not a scrub bypass.
|
||||
let home = dirs::home_dir().expect("home dir for path-scrub test");
|
||||
let home_str = home.to_string_lossy();
|
||||
// Skip if the home path is too short/generic for the scrubber to match.
|
||||
if home_str.len() < 4 {
|
||||
return;
|
||||
}
|
||||
let full = format!("{home_str}/secret-project/src/main.rs");
|
||||
let mut attrs = vec![
|
||||
KeyValue::new("path", full.clone()),
|
||||
KeyValue::new("file_path", full.clone()),
|
||||
KeyValue::new("cwd", full.clone()),
|
||||
];
|
||||
scrub_attributes(&mut attrs);
|
||||
let blob = format!("{attrs:?}");
|
||||
assert!(
|
||||
!blob.contains(home_str.as_ref()),
|
||||
"home path survived allowlisted scrub: {blob}"
|
||||
);
|
||||
assert!(
|
||||
blob.contains("main.rs") || blob.contains("[HOME]") || blob.contains("~"),
|
||||
"expected redacted path to retain a filename or home marker: {blob}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_key_value_is_secret_and_path_scrubbed() {
|
||||
// Free-form `error` strings are allowlisted for classification labels;
|
||||
// any secret/path content that sneaks in must still be scrubbed.
|
||||
let home = dirs::home_dir().expect("home dir");
|
||||
let home_str = home.to_string_lossy();
|
||||
let msg =
|
||||
format!("failed reading {home_str}/.config/creds with sk-CANARYabcdefghij1234567890");
|
||||
let mut attrs = vec![KeyValue::new("error", msg)];
|
||||
scrub_attributes(&mut attrs);
|
||||
let blob = format!("{attrs:?}");
|
||||
assert!(!blob.contains("CANARY"), "secret survived in error: {blob}");
|
||||
if home_str.len() >= 4 {
|
||||
assert!(
|
||||
!blob.contains(home_str.as_ref()),
|
||||
"home path survived in error: {blob}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue