Synced from monorepo

Changes:
- grok-shell: request workspaces:read/write OAuth2 scopes
- security: fix SSRF bypass via HTTP redirect in hook runner
- fix(grok-build): enterprise STT WSS URL + API-key voice bearer
- Harden identity-change purge and sync-marker invariants
- sandbox + workspace-server: delete the legacy ready-file arm
- Show billing URL when browser cannot open
- fix(pager): show folder-trust UI in minimal mode
- fix(pager): drain task_backgrounded before no-wait headless exit
- grok-agent-sdk: stop SDK-spawned agents from staging self-updates they can never adopt
- Split settings_modal into directory module
- Delegate VS Code SSH file links
- grok-shell: release the workspace session binding when a session is removed
- keep skills reachable when their name collides with a client builtin
- Preserve semantic link targets
This commit is contained in:
grokkybara[bot] 2026-07-16 20:27:30 +01:00
commit 8adf9013a0
117 changed files with 16998 additions and 14540 deletions

View file

@ -1,24 +1,26 @@
//! Minimal-mode sign-in rendering for the live region.
//! Minimal-mode sign-in / folder-trust rendering for the live region.
//!
//! Before any agent session exists (unauthenticated / folder-trust pending) the
//! minimal live region shows the sign-in flow itself — device or external-command
//! flow, a sign-in error, or a brief "starting" transient once authenticated —
//! since minimal has no welcome screen. [`draw_live`](super::live::draw_live)
//! computes a [`MinimalAuthHint`] from the app's [`AuthState`] and renders it via
//! [`render_auth`].
//! flow, a sign-in error, the folder-trust question, or a brief "starting"
//! transient once both gates are open — since minimal has no welcome screen.
//! [`draw_live`](super::live::draw_live) computes a [`MinimalAuthHint`] from the
//! app's [`AuthState`] + [`TrustState`] and renders it via [`render_auth`].
use std::path::PathBuf;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use xai_grok_pager::app::app_view::AuthState;
use xai_grok_pager::app::app_view::{AuthState, TrustState};
use xai_grok_pager::theme::Theme;
/// What the minimal live region shows when there is no active agent yet: the
/// in-region sign-in flow (device or external-command), a sign-in error, or a
/// brief "starting" transient once authenticated. Computed from [`AuthState`]
/// before the draw closure so the closure can own it.
/// in-region sign-in flow (device or external-command), a sign-in error, the
/// folder-trust question, or a brief "starting" transient once authenticated
/// (and trusted). Computed before the draw closure so the closure can own it.
pub(super) enum MinimalAuthHint {
/// Interactive sign-in underway — show the URL (when known) and the device
/// code (when the URL carries one). Covers device flow and the external
@ -30,12 +32,26 @@ pub(super) enum MinimalAuthHint {
},
/// The last sign-in attempt failed; show the error.
Failed(String),
/// Authenticated — the session is being created (brief transient).
/// Authenticated, but the cwd has untrusted repo-local config — ask before
/// creating a session. Input (y/Enter trust, n/Esc quit) is handled by the
/// welcome interceptor in `AppView::handle_input`; this is render-only.
TrustFolder { workspace: PathBuf },
/// Authenticated (+ trusted) — the session is being created (brief transient).
Starting,
}
/// Map the app's [`AuthState`] to what the no-agent live region should show.
pub(super) fn minimal_auth_hint(auth: &AuthState) -> MinimalAuthHint {
/// Map the app's auth + trust state to what the no-agent live region should show.
///
/// Mirrors the welcome screen's gate order: trust is only offered after auth is
/// `Done`, when the user has access and is not ZDR-blocked (those gates already
/// block sessions, and the input interceptor only answers trust under the same
/// conditions).
pub(super) fn minimal_auth_hint(
auth: &AuthState,
trust: &TrustState,
has_access: bool,
is_zdr_blocked: bool,
) -> MinimalAuthHint {
match auth {
AuthState::Authenticating { auth_url, .. } => MinimalAuthHint::SigningIn {
url: auth_url.clone(),
@ -51,10 +67,59 @@ pub(super) fn minimal_auth_hint(auth: &AuthState) -> MinimalAuthHint {
url: None,
code: None,
},
AuthState::Done if has_access && !is_zdr_blocked => {
if let TrustState::Pending { workspace } = trust {
MinimalAuthHint::TrustFolder {
workspace: workspace.clone(),
}
} else {
MinimalAuthHint::Starting
}
}
AuthState::Done => MinimalAuthHint::Starting,
}
}
/// Rows the no-agent live region needs for `hint` (before path wrap). Used by
/// the overlay host so the viewport grows enough to show the trust question
/// instead of clipping to the idle prompt height.
pub(super) fn auth_hint_rows(hint: &MinimalAuthHint, width: u16) -> u16 {
match hint {
// header + blank + "Opening browser…"
MinimalAuthHint::SigningIn { url: None, code: _ } => 3,
// header + blank + "Open this URL" + url rows + optional code block +
// blank + "Waiting…"
MinimalAuthHint::SigningIn {
url: Some(url),
code,
} => {
let url_rows = wrapped_char_rows(url, width);
let code_rows = if code.is_some() { 2 } else { 0 }; // blank + "Code: …"
3 + url_rows + code_rows + 2
}
// "Sign-in failed" + blank + error
MinimalAuthHint::Failed(_) => 3,
// question + path rows + blank + 2 warning + blank + 2 menu + blank + hint
MinimalAuthHint::TrustFolder { workspace } => {
let path = workspace.display().to_string();
let path_rows = wrapped_char_rows(&path, width);
1 + path_rows + 1 + 2 + 1 + 2 + 1 + 1
}
MinimalAuthHint::Starting => 1,
}
}
/// How many rows `text` needs when painted char-by-char at `width` (no
/// wrap-inserted spaces) — same layout as [`render_url`].
fn wrapped_char_rows(text: &str, width: u16) -> u16 {
let width = width.max(1) as usize;
let chars = text.chars().filter(|c| !c.is_control()).count();
if chars == 0 {
return 1;
}
chars.div_ceil(width) as u16
}
/// Parse the device-flow `user_code` from a verification URL (`None` if absent
/// or malformed). Mirrors `views::welcome::extract_user_code`, kept local so
/// minimal does not depend on welcome-screen internals.
@ -120,8 +185,8 @@ fn render_url(
y.saturating_add(1)
}
/// Render the sign-in flow (or transient status) in the live region when no
/// agent exists yet. Top-aligned in `area`; clips to its height.
/// Render the sign-in / trust flow (or transient status) in the live region when
/// no agent exists yet. Top-aligned in `area`; clips to its height.
pub(super) fn render_auth(buf: &mut Buffer, area: Rect, theme: &Theme, hint: &MinimalAuthHint) {
if area.width == 0 || area.height == 0 {
return;
@ -221,6 +286,77 @@ pub(super) fn render_auth(buf: &mut Buffer, area: Rect, theme: &Theme, hint: &Mi
Line::from(Span::styled(err.clone(), gray)),
);
}
MinimalAuthHint::TrustFolder { workspace } => {
// Mirrors `render_welcome_trust` copy, flush-left for minimal.
y = put_line(
buf,
area,
y,
bottom,
Line::from(Span::styled(
"Do you trust the contents of this directory?",
bold,
)),
);
y = render_url(
buf,
area,
y,
bottom,
&workspace.display().to_string(),
Style::default().fg(theme.accent_user).bg(Color::Reset),
);
y = put_line(buf, area, y, bottom, Line::default());
y = put_line(
buf,
area,
y,
bottom,
Line::from(Span::styled(
"Grok Build may run or modify contents in this directory,",
gray,
)),
);
y = put_line(
buf,
area,
y,
bottom,
Line::from(Span::styled("posing security risks.", gray)),
);
y = put_line(buf, area, y, bottom, Line::default());
y = put_line(
buf,
area,
y,
bottom,
Line::from(vec![
Span::styled("y", bold),
Span::styled(" Yes, proceed", gray),
]),
);
y = put_line(
buf,
area,
y,
bottom,
Line::from(vec![
Span::styled("n", bold),
Span::styled(" No, quit", gray),
]),
);
y = put_line(buf, area, y, bottom, Line::default());
let _ = put_line(
buf,
area,
y,
bottom,
Line::from(Span::styled(
"Enter or y to trust \u{00b7} n or Esc to quit",
gray,
)),
);
}
MinimalAuthHint::Starting => {
let _ = put_line(
buf,
@ -257,6 +393,8 @@ mod tests {
fn auth_hint_maps_auth_state() {
use xai_grok_pager::app::app_view::AuthMode;
let trust_done = TrustState::Done;
// Device flow → SigningIn carrying the URL and the parsed code.
let st = AuthState::Authenticating {
request_seq: 1,
@ -264,7 +402,7 @@ mod tests {
auth_url: Some("https://accounts.x.ai/device?user_code=ABCD-EFGH".into()),
mode: AuthMode::Device,
};
match minimal_auth_hint(&st) {
match minimal_auth_hint(&st, &trust_done, true, false) {
MinimalAuthHint::SigningIn { url, code } => {
assert_eq!(
url.as_deref(),
@ -282,7 +420,7 @@ mod tests {
auth_url: Some("https://provider.example/login".into()),
mode: AuthMode::Command,
};
match minimal_auth_hint(&st) {
match minimal_auth_hint(&st, &trust_done, true, false) {
MinimalAuthHint::SigningIn { url, code } => {
assert_eq!(url.as_deref(), Some("https://provider.example/login"));
assert!(code.is_none());
@ -291,17 +429,52 @@ mod tests {
}
assert!(matches!(
minimal_auth_hint(&AuthState::Done),
minimal_auth_hint(&AuthState::Done, &trust_done, true, false),
MinimalAuthHint::Starting
));
assert!(matches!(
minimal_auth_hint(&AuthState::Pending {
error: Some("nope".into())
}),
minimal_auth_hint(
&AuthState::Pending {
error: Some("nope".into())
},
&trust_done,
true,
false
),
MinimalAuthHint::Failed(_)
));
}
#[test]
fn auth_hint_maps_pending_trust_after_auth() {
let trust = TrustState::Pending {
workspace: PathBuf::from("/tmp/untrusted-repo"),
};
match minimal_auth_hint(&AuthState::Done, &trust, true, false) {
MinimalAuthHint::TrustFolder { workspace } => {
assert_eq!(workspace, PathBuf::from("/tmp/untrusted-repo"));
}
_ => panic!("expected TrustFolder"),
}
// Access / ZDR gates suppress the trust question (matches welcome +
// the input interceptor).
assert!(matches!(
minimal_auth_hint(&AuthState::Done, &trust, false, false),
MinimalAuthHint::Starting
));
assert!(matches!(
minimal_auth_hint(&AuthState::Done, &trust, true, true),
MinimalAuthHint::Starting
));
// Trust is not offered while auth is still in flight.
assert!(matches!(
minimal_auth_hint(&AuthState::Pending { error: None }, &trust, true, false),
MinimalAuthHint::SigningIn { .. }
));
}
#[test]
fn render_auth_shows_url_and_code() {
let theme = Theme::current();
@ -312,14 +485,7 @@ mod tests {
code: Some("ABCD-EFGH".into()),
};
render_auth(&mut buf, area, &theme, &hint);
let mut text = String::new();
for y in 0..area.height {
for x in 0..area.width {
if let Some(c) = buf.cell((x, y)) {
text.push_str(c.symbol());
}
}
}
let text = buffer_text(&buf, area);
assert!(text.contains("Sign in to Grok"), "header: {text:?}");
assert!(text.contains("accounts.x.ai/device"), "url: {text:?}");
assert!(text.contains("ABCD-EFGH"), "device code: {text:?}");
@ -328,4 +494,52 @@ mod tests {
"waiting line: {text:?}"
);
}
#[test]
fn render_auth_shows_trust_question() {
let theme = Theme::current();
let area = Rect::new(0, 0, 80, 14);
let mut buf = Buffer::empty(area);
let hint = MinimalAuthHint::TrustFolder {
workspace: PathBuf::from("/home/agent/project"),
};
render_auth(&mut buf, area, &theme, &hint);
let text = buffer_text(&buf, area);
assert!(
text.contains("Do you trust the contents of this directory?"),
"question: {text:?}"
);
assert!(
text.contains("/home/agent/project"),
"workspace path: {text:?}"
);
assert!(text.contains("Yes, proceed"), "yes option: {text:?}");
assert!(text.contains("No, quit"), "no option: {text:?}");
assert!(text.contains("Enter or y to trust"), "hint line: {text:?}");
assert!(text.contains("posing security risks"), "warning: {text:?}");
}
#[test]
fn auth_hint_rows_covers_trust_path_wrap() {
let long = "x".repeat(200);
let hint = MinimalAuthHint::TrustFolder {
workspace: PathBuf::from(long),
};
let rows = auth_hint_rows(&hint, 40);
// path alone needs 5 rows at width 40 (200/40); total well above base.
assert!(rows >= 12, "expected room for wrapped path, got {rows}");
}
fn buffer_text(buf: &Buffer, area: Rect) -> String {
let mut text = String::new();
for y in 0..area.height {
for x in 0..area.width {
if let Some(c) = buf.cell((x, y)) {
text.push_str(c.symbol());
}
}
text.push('\n');
}
text
}
}

View file

@ -71,7 +71,12 @@ pub(super) fn prompt_style(
/// Draw the pinned live region (tail + status + prompt) into the inline viewport.
pub fn draw_live(app: &mut AppView, terminal: &mut PagerTerminal) {
let force_todos = minimal_api::minimal_show_todos(app);
let auth_hint = crate::auth::minimal_auth_hint(&app.auth_state);
let auth_hint = crate::auth::minimal_auth_hint(
&app.auth_state,
&app.trust_state,
app.has_access(),
app.is_zdr_blocked(),
);
let pending_hint = minimal_pending_hint(&app.pending_action);
let transcript_hint = if minimal_api::minimal_ctrl_o_opens_transcript(app) {
"ctrl+o transcript"

View file

@ -230,7 +230,16 @@ fn compute_target(app: &mut AppView, term_h: u16, width: u16) -> u16 {
let content_w = width as usize;
let ActiveView::Agent(id) = &app.active_view else {
return base;
// No agent yet: size for the in-region sign-in / folder-trust UI so the
// trust question isn't clipped to the idle prompt height.
let hint = super::auth::minimal_auth_hint(
&app.auth_state,
&app.trust_state,
app.has_access(),
app.is_zdr_blocked(),
);
let needed = super::auth::auth_hint_rows(&hint, width);
return needed.max(base).min(ceiling);
};
let id = *id;
let Some(agent) = app.agents.get_mut(&id) else {