Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
571 lines
21 KiB
Rust
571 lines
21 KiB
Rust
//! Tests for permission request selection, follow-ups, and queue draining.
|
|
|
|
use super::*;
|
|
|
|
/// `ConfirmResetSetting
|
|
/// { Reset }` on `permission_mode` (the security-critical SHELL Enum)
|
|
/// dispatches `Action::SetPermissionMode(PermissionModeKind::Ask)`
|
|
/// (the typed Action, per the modal-commit ↔ typed-setter
|
|
/// rule) via recursive dispatch. Emits
|
|
/// `Effect::PersistPermissionMode` — verifies the recursive
|
|
/// dispatch reaches the YOLO pipeline through
|
|
/// `set_permission_mode` rather than the legacy `set_yolo_mode`.
|
|
#[test]
|
|
fn dispatch_confirm_reset_setting_reset_dispatches_set_permission_mode_for_permission_mode() {
|
|
use crate::views::modal::ResetSettingsResult;
|
|
let mut app = test_app_with_agent();
|
|
// Flip yolo on first (default is OFF = "ask").
|
|
let _ = dispatch(Action::SetYoloMode(true), &mut app);
|
|
assert!(app.agents[&AgentId(0)].session.is_yolo());
|
|
|
|
setup_reset_confirm_open(&mut app, "permission_mode");
|
|
|
|
let effects = dispatch(
|
|
Action::ConfirmResetSetting {
|
|
choice: ResetSettingsResult::Reset,
|
|
},
|
|
&mut app,
|
|
);
|
|
|
|
// Recursive dispatch into Action::SetYoloMode(false) emits a
|
|
// PersistPermissionMode effect.
|
|
let has_persist = effects
|
|
.iter()
|
|
.any(|e| matches!(e, Effect::PersistPermissionMode { .. }));
|
|
assert!(
|
|
has_persist,
|
|
"Reset of permission_mode must emit PersistPermissionMode, got {effects:?}",
|
|
);
|
|
// Agent's yolo flag is reset to default (off).
|
|
assert!(
|
|
!app.agents[&AgentId(0)].session.is_yolo(),
|
|
"agent.session.yolo_mode must be reset to default (off)",
|
|
);
|
|
}
|
|
|
|
/// **Security-critical:** YOLO ON must drain the per-agent
|
|
/// `permission_queue` with `AllowOnce` responses. If this drain
|
|
/// path regresses (e.g., the setter falls back to `Cancelled`
|
|
/// without an `AllowOnce` lookup), the user enables YOLO and
|
|
/// their queued permissions silently get rejected.
|
|
#[test]
|
|
fn set_yolo_mode_on_drains_permission_queue_with_allow_once() {
|
|
use crate::views::permission_view::{PermissionFocus, PermissionViewState};
|
|
use std::sync::Arc;
|
|
|
|
let mut app = test_app_with_agent();
|
|
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
|
|
|
// Inject a fake queued permission. The drain semantics use
|
|
// `find(|o| o.kind == AllowOnce)` so we need ≥1 AllowOnce
|
|
// option for the test to exercise the happy path.
|
|
let (response_tx, mut response_rx) = tokio::sync::oneshot::channel();
|
|
let request = acp::RequestPermissionRequest::new(
|
|
acp::SessionId::new(Arc::from("test-sess")),
|
|
acp::ToolCallUpdate::new(
|
|
acp::ToolCallId::new(Arc::from("tc-1")),
|
|
acp::ToolCallUpdateFields::default(),
|
|
),
|
|
vec![
|
|
acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new(Arc::from("opt-allow-once")),
|
|
"Allow once",
|
|
acp::PermissionOptionKind::AllowOnce,
|
|
),
|
|
acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new(Arc::from("opt-reject")),
|
|
"Reject",
|
|
acp::PermissionOptionKind::RejectOnce,
|
|
),
|
|
],
|
|
);
|
|
let options = request.options.clone();
|
|
agent.permission_queue.push_back(PermissionViewState {
|
|
request: kigi_acp_lib::AcpArgs {
|
|
request,
|
|
response_tx,
|
|
},
|
|
id: 1,
|
|
focus: PermissionFocus::Options,
|
|
options,
|
|
active_idx: 0,
|
|
bash_highlights: None,
|
|
bash_selection_count: 0,
|
|
bash_command_raw: None,
|
|
mcp_scope: None,
|
|
title: "test".to_string(),
|
|
description: vec![],
|
|
args_expanded: false,
|
|
desc_scroll: 0,
|
|
subagent_label: None,
|
|
options_area_height: 0,
|
|
options_scroll_offset: 0,
|
|
});
|
|
assert_eq!(agent.permission_queue.len(), 1);
|
|
|
|
let _ = dispatch(Action::SetYoloMode(true), &mut app);
|
|
|
|
// Queue is drained.
|
|
assert!(
|
|
app.agents[&AgentId(0)].permission_queue.is_empty(),
|
|
"YOLO ON must drain the permission_queue",
|
|
);
|
|
// Verify the `AllowOnce` response was actually sent (NOT
|
|
// `Cancelled`). The drain semantics use `find(|o| o.kind ==
|
|
// AllowOnce)` — a regression to `Cancelled` here would
|
|
// silently reject every queued permission when the user
|
|
// enables YOLO, which is the exact security failure mode
|
|
// this test prevents.
|
|
match response_rx.try_recv() {
|
|
Ok(Ok(acp::RequestPermissionResponse {
|
|
outcome:
|
|
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome {
|
|
option_id,
|
|
..
|
|
}),
|
|
..
|
|
})) => {
|
|
assert_eq!(
|
|
option_id,
|
|
acp::PermissionOptionId::new(Arc::from("opt-allow-once")),
|
|
"the drain must select the AllowOnce option (NOT Cancelled / RejectOnce)",
|
|
);
|
|
}
|
|
other => panic!(
|
|
"queue drain must send an `AllowOnce` Selected response, got {other:?} — \
|
|
security regression: queued permissions are NOT being auto-approved on YOLO ON",
|
|
),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn permission_select_clears_double_click_tracker_for_next_prompt() {
|
|
use crate::views::permission_view::PermissionFocus;
|
|
use std::sync::Arc;
|
|
|
|
let mut app = test_app_with_agent();
|
|
let _rx_front = enqueue_permission_with_enable_always_approve(&mut app);
|
|
let _rx_next = enqueue_permission_with_enable_always_approve(&mut app);
|
|
|
|
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
|
agent.permission_queue.get_mut(1).unwrap().focus = PermissionFocus::FollowupInput;
|
|
agent.last_permission_click = Some((Instant::now(), 1));
|
|
|
|
let _ = dispatch(
|
|
Action::PermissionSelect(acp::PermissionOptionId::new(Arc::from("opt-allow-once"))),
|
|
&mut app,
|
|
);
|
|
|
|
let agent = &app.agents[&AgentId(0)];
|
|
assert_eq!(agent.permission_queue.len(), 1);
|
|
assert!(
|
|
agent.last_permission_click.is_none(),
|
|
"armed click on the resolved prompt must not pair with a click on the next prompt"
|
|
);
|
|
assert_eq!(
|
|
agent.permission_queue.front().unwrap().focus,
|
|
PermissionFocus::Options,
|
|
"next front must be reset to Options"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn drain_permission_queue_clears_double_click_tracker() {
|
|
let mut app = test_app_with_agent();
|
|
let _rx = enqueue_permission_with_enable_always_approve(&mut app);
|
|
|
|
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
|
agent.last_permission_click = Some((Instant::now(), 1));
|
|
|
|
drain_permission_queue(agent);
|
|
|
|
assert!(agent.permission_queue.is_empty());
|
|
assert!(
|
|
agent.last_permission_click.is_none(),
|
|
"turn-end/turn-cancel drain must invalidate the armed click"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn set_permission_mode_always_approve_blocked_by_policy_pin() {
|
|
use crate::app::actions::PermissionModeKind;
|
|
use crate::views::modal::ActiveModal;
|
|
let mut app = test_app_with_agent();
|
|
app.yolo_policy_block = Some(POLICY_WARNING);
|
|
// Open the settings modal so the blocked path's snapshot refresh is
|
|
// exercised: the modal must keep showing the live (non-yolo) value.
|
|
let _ = dispatch(Action::OpenSettings, &mut app);
|
|
|
|
let effects = dispatch(
|
|
Action::SetPermissionMode(PermissionModeKind::AlwaysApprove),
|
|
&mut app,
|
|
);
|
|
|
|
assert!(
|
|
effects.is_empty(),
|
|
"blocked modal commit must not persist, got {effects:?}",
|
|
);
|
|
assert!(!app.agents[&AgentId(0)].session.is_yolo());
|
|
assert_eq!(
|
|
app.current_ui.permission_mode, None,
|
|
"canonical mirror must stay untouched"
|
|
);
|
|
let agent = app.agents.get(&AgentId(0)).unwrap();
|
|
let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
|
|
panic!("Settings modal must remain open across the blocked dispatch")
|
|
};
|
|
assert!(
|
|
!state.pager_snapshot.yolo_mode,
|
|
"modal snapshot must show the live (non-yolo) value after the block",
|
|
);
|
|
assert_ne!(
|
|
state.ui_snapshot.permission_mode.as_deref(),
|
|
Some("always-approve"),
|
|
"modal canonical must not show the refused mode",
|
|
);
|
|
assert_eq!(agent_toast(&app).as_deref(), Some(POLICY_WARNING));
|
|
|
|
// Non-yolo kinds still commit under the pin.
|
|
let effects = dispatch(Action::SetPermissionMode(PermissionModeKind::Ask), &mut app);
|
|
assert_eq!(effects.len(), 1, "Ask must persist under the pin");
|
|
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask"));
|
|
}
|
|
|
|
/// SetPermissionMode(Auto) persists auto and does not enable yolo.
|
|
#[test]
|
|
fn set_permission_mode_auto_persists_without_yolo() {
|
|
use crate::app::actions::PermissionModeKind;
|
|
let mut app = test_app_with_agent();
|
|
let effects = dispatch(
|
|
Action::SetPermissionMode(PermissionModeKind::Auto),
|
|
&mut app,
|
|
);
|
|
assert!(!app.agents[&AgentId(0)].session.is_yolo());
|
|
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("auto"));
|
|
assert!(
|
|
effects.iter().any(|e| matches!(
|
|
e,
|
|
Effect::PersistPermissionMode {
|
|
canonical: "auto",
|
|
..
|
|
}
|
|
)),
|
|
"expected PersistPermissionMode(auto), got {effects:?}"
|
|
);
|
|
}
|
|
|
|
/// Feature gate OFF: a SetPermissionMode(Auto) commit (e.g. from the
|
|
/// settings modal) degrades to Ask — same `app.auto_mode_gate` source the
|
|
/// Shift+Tab cycle uses, so the two never disagree.
|
|
#[test]
|
|
fn set_permission_mode_auto_degrades_to_ask_when_gated_off() {
|
|
use crate::app::actions::PermissionModeKind;
|
|
let mut app = test_app_with_agent();
|
|
app.auto_mode_gate = false;
|
|
let effects = dispatch(
|
|
Action::SetPermissionMode(PermissionModeKind::Auto),
|
|
&mut app,
|
|
);
|
|
assert_eq!(
|
|
app.current_ui.permission_mode.as_deref(),
|
|
Some("ask"),
|
|
"gate OFF: Auto commit must land on Ask, not auto"
|
|
);
|
|
assert!(
|
|
!effects.iter().any(|e| matches!(
|
|
e,
|
|
Effect::PersistPermissionMode {
|
|
canonical: "auto",
|
|
..
|
|
}
|
|
)),
|
|
"gate OFF: must not persist 'auto', got {effects:?}"
|
|
);
|
|
}
|
|
|
|
/// Rollback with an unknown canonical: defensively defaults to
|
|
/// "ask" (the safe fallback — fewer prompts on a corrupt
|
|
/// rollback value is worse, more prompts is safer).
|
|
///
|
|
/// The previous docstring claimed "logs a
|
|
/// warning and defaults to 'ask'" — the warning log is fired via
|
|
/// `tracing::warn!` in `apply_setting_rollback`'s arm, but the
|
|
/// test doesn't capture/assert it. The fix is documentary: the
|
|
/// test pins the OBSERVABLE behaviour (state defaults to "ask")
|
|
/// and acknowledges that the warn-log is best-effort visibility
|
|
/// for developers, not a contract surface the test enforces.
|
|
/// `tracing_test::traced_test` capture would be more rigorous
|
|
/// but is not currently used in this crate.
|
|
#[test]
|
|
fn rollback_permission_mode_unknown_canonical_defaults_to_ask() {
|
|
use crate::settings::SettingValue;
|
|
let mut app = test_app_with_agent();
|
|
// Pre-set to true.
|
|
let _ = dispatch(Action::SetYoloMode(true), &mut app);
|
|
|
|
// Garbage canonical rolls back to "ask" (the safe default).
|
|
let _ = dispatch(
|
|
Action::TaskComplete(TaskResult::SettingPersistFailed {
|
|
key: "permission_mode",
|
|
rollback_value: SettingValue::Enum("garbage-value"),
|
|
error: "test-error".into(),
|
|
}),
|
|
&mut app,
|
|
);
|
|
|
|
assert!(
|
|
!app.agents[&AgentId(0)].session.is_yolo(),
|
|
"unknown canonical → safe default (ask = no auto-approve)",
|
|
);
|
|
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask"));
|
|
// The failure toast is the standard
|
|
// `✗ Could not save permission_mode: …` format. A future
|
|
// enhancement could differentiate "schema corruption" from
|
|
// "real disk failure" in the toast text, but currently the
|
|
// user sees the same wording; pinned here so a future
|
|
// divergence is intentional.
|
|
}
|
|
|
|
/// Rollback path refreshes open modal
|
|
/// snapshots in the same way the success path does. Mirror of
|
|
/// `set_yolo_mode_refreshes_open_modal_snapshots` for the
|
|
/// `apply_setting_rollback` entry into `set_yolo_mode_inner`.
|
|
/// Without this, a modal that's open when a disk write fails
|
|
/// shows a stale "always-approve" indicator after the state
|
|
/// has rolled back to "ask".
|
|
#[test]
|
|
fn rollback_permission_mode_refreshes_open_modal_snapshots() {
|
|
use crate::settings::SettingValue;
|
|
use crate::views::modal::ActiveModal;
|
|
|
|
let mut app = test_app_with_agent();
|
|
// Pre-set yolo=true via the typed setter so the rollback
|
|
// captures real prior state.
|
|
let _ = dispatch(Action::SetYoloMode(true), &mut app);
|
|
// Open the modal AFTER the optimistic toggle so the open-time
|
|
// snapshot reflects yolo=true.
|
|
let _ = dispatch(Action::OpenSettings, &mut app);
|
|
let agent = app.agents.get(&AgentId(0)).unwrap();
|
|
let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
|
|
panic!("expected Settings modal");
|
|
};
|
|
assert!(
|
|
state.pager_snapshot.yolo_mode,
|
|
"pre-rollback snapshot reflects optimistic state (yolo=true)",
|
|
);
|
|
|
|
// Simulate disk-write failure → rollback to "ask".
|
|
let _ = dispatch(
|
|
Action::TaskComplete(TaskResult::SettingPersistFailed {
|
|
key: "permission_mode",
|
|
rollback_value: SettingValue::Enum("ask"),
|
|
error: "test-error".into(),
|
|
}),
|
|
&mut app,
|
|
);
|
|
|
|
// The modal's snapshot MUST refresh to the rolled-back value.
|
|
let agent = app.agents.get(&AgentId(0)).unwrap();
|
|
let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
|
|
panic!("modal must stay open after rollback");
|
|
};
|
|
assert!(
|
|
!state.pager_snapshot.yolo_mode,
|
|
"rollback path MUST refresh pager_snapshot.yolo_mode (false after revert)",
|
|
);
|
|
assert_eq!(
|
|
state.ui_snapshot.permission_mode.as_deref(),
|
|
Some("ask"),
|
|
"rollback path MUST refresh ui_snapshot.permission_mode to 'ask'",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn set_permission_mode_ask_emits_brand_consistent_toast() {
|
|
use crate::app::actions::PermissionModeKind;
|
|
let mut app = test_app_with_agent();
|
|
// Pre-set to AlwaysApprove so the Ask dispatch is a real
|
|
// transition (avoids idempotent fast-path).
|
|
let _ = dispatch(Action::SetYoloMode(true), &mut app);
|
|
// Clear toast so we observe the Ask dispatch's fresh toast.
|
|
app.agents.get_mut(&AgentId(0)).unwrap().toast = None;
|
|
|
|
let effects = dispatch(Action::SetPermissionMode(PermissionModeKind::Ask), &mut app);
|
|
|
|
assert!(!app.agents[&AgentId(0)].session.is_yolo());
|
|
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask"));
|
|
|
|
// Toast brands as "Permission mode" not
|
|
// "Always-approve". Previously the Ask arm reused `yolo_toast(false)`
|
|
// which produced "✓ Always-approve: off" — a brand mismatch.
|
|
let toast = app.agents[&AgentId(0)]
|
|
.toast
|
|
.as_ref()
|
|
.map(|(s, _)| s.clone())
|
|
.expect("toast must be set");
|
|
assert_eq!(
|
|
toast, "\u{2713} Permission mode: Ask",
|
|
"PR 11 R1 G-3 #11: Ask toast must brand as 'Permission mode' not 'Always-approve'",
|
|
);
|
|
|
|
// Effect carries the new canonical + the prior canonical
|
|
// (was "always-approve" from the test-setup pre-set).
|
|
assert_eq!(effects.len(), 1);
|
|
match &effects[0] {
|
|
Effect::PersistPermissionMode {
|
|
canonical, persist, ..
|
|
} => {
|
|
assert_eq!(*canonical, "ask");
|
|
assert_eq!(
|
|
*persist,
|
|
crate::app::actions::PermissionModePersist::WithRollback("always-approve"),
|
|
"prior canonical was 'always-approve' (pre-set by SetYoloMode(true))",
|
|
);
|
|
}
|
|
other => panic!("expected PersistPermissionMode, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Regression test. A `--yolo`
|
|
/// startup sets `agent.session.yolo_mode = true` but leaves
|
|
/// `app.current_ui.permission_mode` at `None`. Without the
|
|
/// LIVE-precedence capture, dispatching `SetPermissionMode(Default)`
|
|
/// would produce `WithRollback("ask")` — diverging the pager from
|
|
/// the shell on disk failure (the ACP suppress-on-failure gate
|
|
/// keeps the shell at YOLO, but the pager would roll back to
|
|
/// non-YOLO). This test pins the LIVE-precedence fix.
|
|
#[test]
|
|
fn set_permission_mode_with_live_yolo_and_no_ui_mirror_rolls_back_to_always_approve() {
|
|
use crate::app::actions::PermissionModeKind;
|
|
let mut app = test_app_with_agent();
|
|
// Simulate `--yolo` startup: agent yolo + default_yolo set,
|
|
// but `current_ui.permission_mode = None` (config has no
|
|
// `[ui] permission_mode` setting).
|
|
app.agents.get_mut(&AgentId(0)).unwrap().session.yolo_mode = true;
|
|
app.default_yolo = true;
|
|
app.current_ui.permission_mode = None;
|
|
|
|
let effects = dispatch(
|
|
Action::SetPermissionMode(PermissionModeKind::Default),
|
|
&mut app,
|
|
);
|
|
|
|
// The dispatch flipped yolo off (Default projects onto
|
|
// bool=false) and set the canonical to "default".
|
|
assert!(!app.agents[&AgentId(0)].session.is_yolo());
|
|
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("default"));
|
|
|
|
// **Rollback contract.** Rollback must target
|
|
// "always-approve" (the LIVE state at dispatch time), NOT
|
|
// "ask" (a bool-projected guess from the None mirror).
|
|
match &effects[0] {
|
|
Effect::PersistPermissionMode { persist, .. } => {
|
|
assert_eq!(
|
|
*persist,
|
|
crate::app::actions::PermissionModePersist::WithRollback("always-approve"),
|
|
"PR 11 R1 Security #8: LIVE yolo state must take precedence over the \
|
|
None on-disk mirror when computing the rollback canonical — \
|
|
otherwise a `--yolo` startup + Default-commit + disk-failure diverges \
|
|
the pager from the shell",
|
|
);
|
|
}
|
|
other => panic!("expected PersistPermissionMode, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// `apply_setting_rollback("permission_mode",
|
|
/// Enum("default"))` — the rollback arm that preserves the
|
|
/// "default" canonical through a failed-persist. The headline
|
|
/// architectural contract: rolling back to "default" must NOT
|
|
/// collapse onto "ask" via the inner's bool projection.
|
|
#[test]
|
|
fn rollback_permission_mode_default_canonical_preserves_default() {
|
|
use crate::settings::SettingValue;
|
|
let mut app = test_app_with_agent();
|
|
// Pre-flip to YOLO so the rollback has somewhere to roll
|
|
// back FROM.
|
|
let _ = dispatch(Action::SetYoloMode(true), &mut app);
|
|
assert!(app.agents[&AgentId(0)].session.is_yolo());
|
|
assert_eq!(
|
|
app.current_ui.permission_mode.as_deref(),
|
|
Some("always-approve"),
|
|
);
|
|
|
|
// Simulate disk-write failure with `rollback_value =
|
|
// Enum("default")`.
|
|
let effects = dispatch(
|
|
Action::TaskComplete(TaskResult::SettingPersistFailed {
|
|
key: "permission_mode",
|
|
rollback_value: SettingValue::Enum("default"),
|
|
error: "simulated".into(),
|
|
}),
|
|
&mut app,
|
|
);
|
|
|
|
// Rollback path MUST NOT re-emit any Effect — that would
|
|
// loop on persistent disk failure.
|
|
assert!(
|
|
effects.is_empty(),
|
|
"rollback path must not re-emit Effects, got {effects:?}",
|
|
);
|
|
|
|
// Yolo flipped to false (Default projects onto bool=false).
|
|
assert!(
|
|
!app.agents[&AgentId(0)].session.is_yolo(),
|
|
"Default projects onto yolo=false; agent.session.yolo_mode must flip back",
|
|
);
|
|
// Canonical preserved as "default" — the headline
|
|
// contract. Without the post-inner override in the rollback
|
|
// arm, the inner's bool-projection write would leave this
|
|
// at "ask".
|
|
assert_eq!(
|
|
app.current_ui.permission_mode.as_deref(),
|
|
Some("default"),
|
|
"PR 11 R1 Tests #22: rollback to 'default' canonical must NOT collapse \
|
|
onto 'ask' — the post-inner override restores the canonical",
|
|
);
|
|
}
|
|
|
|
/// Non-empty permission_queue → NeedsInput.
|
|
#[test]
|
|
fn classify_top_level_permission_queue_non_empty_is_needs_input() {
|
|
use crate::views::dashboard::{RowState, classify_top_level};
|
|
let mut app = test_app_with_agent();
|
|
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
|
let _rx = push_synthetic_permission(agent, 1, vec![("allow", "Allow")]);
|
|
assert_eq!(classify_top_level(agent), RowState::NeedsInput);
|
|
}
|
|
|
|
#[test]
|
|
fn permission_select_reject_does_not_steer_sticky_cursor() {
|
|
use crate::appearance::permission_cursor::{
|
|
DefaultSelectedPermission, last_used_permission, set_last_used_permission,
|
|
};
|
|
use std::sync::Arc;
|
|
|
|
let mut app = test_app_with_agent();
|
|
let _rx_allow = enqueue_permission_with_enable_always_approve(&mut app);
|
|
let _rx_reject = enqueue_permission_with_enable_always_approve(&mut app);
|
|
|
|
set_last_used_permission(DefaultSelectedPermission::AlwaysAllowAllSessions);
|
|
let _ = dispatch(
|
|
Action::PermissionSelect(acp::PermissionOptionId::new(Arc::from("opt-allow-once"))),
|
|
&mut app,
|
|
);
|
|
assert_eq!(
|
|
last_used_permission(),
|
|
DefaultSelectedPermission::AllowOnce,
|
|
"allow selection records the sticky cursor target"
|
|
);
|
|
|
|
let _ = dispatch(
|
|
Action::PermissionSelect(acp::PermissionOptionId::new(Arc::from("opt-reject-once"))),
|
|
&mut app,
|
|
);
|
|
assert_eq!(
|
|
last_used_permission(),
|
|
DefaultSelectedPermission::AllowOnce,
|
|
"reject selection must not steer the sticky cursor"
|
|
);
|
|
}
|