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:
parent
c68e39f604
commit
8adf9013a0
117 changed files with 16998 additions and 14540 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue