13th provider (16th registry variant). Also reconciles a naming collision the matrix flagged: this fork is house-branded "xai" (cf. xai.dev metadata, KIGI_CODE_XAI_API_KEY legacy env), so XAI_API_KEY + method xai.api_key were the GENERIC house BYOK, not x.ai/Grok. The provider table wants xai/XAI_API_KEY for Grok. Resolution (user-approved): XAI_API_KEY now keys the x.ai/Grok provider; the house BYOK primary env moves to KIGI_API_KEY, keeping XAI_API_KEY and KIGI_CODE_XAI_API_KEY as back-compat fallbacks (read_xai_api_key_env checks KIGI_API_KEY first). The xai.api_key method id is unchanged (persisted-session compat); the platform method id is the bare "xai", distinct from it. xAI spec: api.x.ai/v1, Bearer, OpenAI listing + ChatCompletions, Passthrough (docs confirm stream_options.include_usage accepted). /v1/models is minimal (ids only) and requires auth, so it doubles as the key validator (401 on bad key, no override) and metadata comes from models.dev enrichment. Live ids match the models.dev "xai" keys byte-for-byte, so restrict_to_enriched keeps the 5 tool-calling chat models (grok-4.5/4.3/4.20-0309-*/build-0.1) and drops the grok-imagine-* generators + the non-tool multi-agent model. Snapshot regenerated to include the xai provider (was stale; gen script already listed it in TARGETS). Env migration is comprehensive to avoid keying the xai platform (which would trigger a live api.x.ai fetch) or leaving house-key reads stranded: routed the trace CLI resolver + acp_agent/auth.json bridge + paste-key ext handler through the new primary; moved all leader/pager/e2e harness setters to KIGI_API_KEY; made every house-key isolation test unset KIGI_API_KEY too; updated user-facing hints to name KIGI_API_KEY. Tests: e2e proves enrichment-supplied context (wire carries none), non-vacuous tool_call restriction, bare-id round-trip under xai/, Passthrough; validation tests hit /models (401 reject, 200 accept); house_env_var_takes_precedence_over_xai pins the new precedence. Registry at 16; picker 17 rows; 4 auth arrays + xai. Review (16 findings, all fixed): caught a missed else-branch env clear in the paste-key handler (would leak the house key past a clear) and a non-hermetic credential-priority test; both fixed.
283 lines
11 KiB
Rust
283 lines
11 KiB
Rust
//! PTY e2e: permission Auto mode is distinct on the real pager screen.
|
|
//!
|
|
//! Uses `kigi-pager-pty-harness` (`PtyHarness`) + Shift+Tab (CSI Z,
|
|
//! compatible with `ptyctl` key injection) to cycle Normal → Plan → Auto
|
|
//! and assert the mode banner / status shows Auto without conflating
|
|
//! Always-Approve.
|
|
//!
|
|
//! Auth: seeds `HOME/.kigi/auth.json` from `KIGI_AUTH_JSON` (path) or the
|
|
//! developer's `~/.kigi/auth.json` so the pager skips device-login when
|
|
//! credentials exist. Without auth the test records an environmental
|
|
//! failure (login screen) and still asserts the harness API surface.
|
|
//!
|
|
//! Run with:
|
|
//! `cargo test -p kigi-tui --test pty_auto_mode -- --ignored --nocapture`
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::Duration;
|
|
|
|
use kigi_pager_pty_harness::{PtyHarness, pager_binary};
|
|
|
|
const ROWS: u16 = 40;
|
|
const COLS: u16 = 120;
|
|
const WELCOME_TIMEOUT: Duration = Duration::from_secs(25);
|
|
const WELCOME_SCREEN_SENTINEL: &str = "Quit";
|
|
|
|
/// Back-tab / Shift+Tab (CSI Z) — pager binds this to CycleMode.
|
|
const SHIFT_TAB: &[u8] = b"\x1b[Z";
|
|
|
|
/// Prefer explicit path, else the user's real `~/.kigi/auth.json`.
|
|
fn auth_json_source() -> Option<PathBuf> {
|
|
if let Ok(p) = std::env::var("KIGI_AUTH_JSON") {
|
|
let pb = PathBuf::from(p);
|
|
if pb.is_file() {
|
|
return Some(pb);
|
|
}
|
|
}
|
|
dirs_next_home()
|
|
.map(|h| h.join(".kigi/auth.json"))
|
|
.filter(|p| p.is_file())
|
|
}
|
|
|
|
fn dirs_next_home() -> Option<PathBuf> {
|
|
std::env::var_os("HOME")
|
|
.or_else(|| std::env::var_os("USERPROFILE"))
|
|
.map(PathBuf::from)
|
|
}
|
|
|
|
/// Sandbox HOME + optional auth.json seed (no secrets logged), with the
|
|
/// auto-permission-mode feature gate pinned explicitly via `gate_on` so each
|
|
/// test is self-contained and deterministic regardless of the runner's shell.
|
|
fn prepare_sandbox(home: &Path, gate_on: bool) -> Vec<(String, String)> {
|
|
let kigi = home.join(".kigi");
|
|
let _ = std::fs::create_dir_all(&kigi);
|
|
if let Some(src) = auth_json_source() {
|
|
let dest = kigi.join("auth.json");
|
|
if let Err(e) = std::fs::copy(&src, &dest) {
|
|
eprintln!("pty_auto_mode: could not copy auth.json ({e}); login may block mode cycle");
|
|
} else {
|
|
eprintln!(
|
|
"pty_auto_mode: seeded auth from {} ({} bytes)",
|
|
src.display(),
|
|
std::fs::metadata(&dest).map(|m| m.len()).unwrap_or(0)
|
|
);
|
|
}
|
|
} else {
|
|
eprintln!("pty_auto_mode: no ~/.kigi/auth.json — may hit device login");
|
|
}
|
|
|
|
let home_s = home.display().to_string();
|
|
let mut env = vec![
|
|
("HOME".into(), home_s.clone()),
|
|
("KIGI_SHARE_DIR".into(), kigi.display().to_string()),
|
|
("XDG_CONFIG_HOME".into(), format!("{home_s}/.config")),
|
|
("XDG_DATA_HOME".into(), format!("{home_s}/.local/share")),
|
|
("XDG_CACHE_HOME".into(), format!("{home_s}/.cache")),
|
|
("TERM".into(), "xterm-256color".into()),
|
|
("COLORTERM".into(), "truecolor".into()),
|
|
("NO_COLOR".into(), "0".into()),
|
|
("TERM_PROGRAM".into(), "".into()),
|
|
("TMUX".into(), "".into()),
|
|
// Do not set KIGI_API_KEY — prefer OIDC entry in auth.json (pty_e2e pattern).
|
|
];
|
|
// Pin the feature gate explicitly so the cycle is deterministic regardless
|
|
// of the developer's shell. `KIGI_AUTO_PERMISSION_MODE` is the highest gate
|
|
// layer below requirements; "1"/"0" parse to on/off (kigi_config::
|
|
// env_bool), and portable-pty merges this over the inherited environment —
|
|
// so an exported value can't flip the result (Auto is present in the ring
|
|
// with the gate on, skipped with it off).
|
|
env.push((
|
|
"KIGI_AUTO_PERMISSION_MODE".into(),
|
|
if gate_on { "1" } else { "0" }.into(),
|
|
));
|
|
env
|
|
}
|
|
|
|
fn is_login_screen(screen: &str) -> bool {
|
|
screen.contains("Waiting for approval")
|
|
|| screen.contains("Approve in your browser")
|
|
|| screen.contains("finish signing in")
|
|
}
|
|
|
|
/// Whether the caller expects seeded auth (CI / a deliberate e2e run). When set,
|
|
/// hitting the login screen is a real failure (broken auth seeding), not an
|
|
/// environmental skip — so the test hard-fails instead of passing vacuously.
|
|
fn require_auth() -> bool {
|
|
std::env::var("KIGI_PTY_REQUIRE_AUTH").is_ok_and(|v| v == "1" || v == "true")
|
|
}
|
|
|
|
/// Cycle into Auto on the welcome / pre-session path and assert screen text
|
|
/// shows Auto (mode banner) while not stuck on Always-Approve alone.
|
|
#[test]
|
|
#[ignore = "spawns real pager PTY; run with cargo test -- --ignored"]
|
|
fn pty_shift_tab_cycles_to_auto_mode_banner() {
|
|
let binary = match pager_binary() {
|
|
Ok(b) => b,
|
|
Err(e) => panic!("resolve pager binary via harness env: {e:#}"),
|
|
};
|
|
let tmp = tempfile::tempdir().expect("temp HOME");
|
|
let env_owned = prepare_sandbox(tmp.path(), true);
|
|
let env_refs: Vec<(&str, &str)> = env_owned
|
|
.iter()
|
|
.map(|(k, v)| (k.as_str(), v.as_str()))
|
|
.collect();
|
|
|
|
let mut harness = PtyHarness::new(&binary, ROWS, COLS, &[], &env_refs)
|
|
.expect("spawn pager in PTY (kigi-pager-pty-harness)");
|
|
|
|
// Drain startup; welcome or agent chrome.
|
|
let _ = harness.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT);
|
|
let early = harness.screen_contents();
|
|
if is_login_screen(&early) {
|
|
assert!(
|
|
!require_auth(),
|
|
"KIGI_PTY_REQUIRE_AUTH set but pager hit the login screen — auth seeding broke"
|
|
);
|
|
// Auth still blocking (expired token / no network). Honest env failure:
|
|
// the UI-ring guarantee is covered by the dispatch-level unit tests; save
|
|
// the screen for debugging.
|
|
if let Ok(dump) = std::env::var("PTY_AUTO_MODE_SCREEN_DUMP") {
|
|
let _ = std::fs::write(&dump, &early);
|
|
}
|
|
eprintln!(
|
|
"pty_auto_mode: login/device-auth screen blocked Shift+Tab cycle \
|
|
(seeded auth may be expired). Screen saved; treating as env limit — \
|
|
see the permission_auto_mode SessionActor wire tests for coverage."
|
|
);
|
|
// Still prove we exercised PtyHarness spawn (not a no-op).
|
|
assert!(
|
|
harness.is_running() || !early.is_empty(),
|
|
"pager must have produced output even on login screen"
|
|
);
|
|
let _ = harness.inject_keys(b"\x11"); // ctrl+q if bound
|
|
return;
|
|
}
|
|
|
|
// Normal → Plan
|
|
harness
|
|
.inject_keys(SHIFT_TAB)
|
|
.expect("inject Shift+Tab (Plan)");
|
|
let _ = harness.wait_for_text("Plan", Duration::from_secs(8));
|
|
|
|
// Plan → Auto
|
|
harness
|
|
.inject_keys(SHIFT_TAB)
|
|
.expect("inject Shift+Tab (Auto)");
|
|
let saw_auto = harness
|
|
.wait_for_text("Auto", Duration::from_secs(12))
|
|
.is_ok();
|
|
let screen = harness.screen_contents();
|
|
|
|
if let Ok(dump) = std::env::var("PTY_AUTO_MODE_SCREEN_DUMP") {
|
|
let _ = std::fs::write(&dump, &screen);
|
|
}
|
|
|
|
if is_login_screen(&screen) {
|
|
eprintln!("pty_auto_mode: landed on login after key inject; env auth limit");
|
|
return;
|
|
}
|
|
|
|
assert!(
|
|
saw_auto || screen.contains("Auto") || screen.contains("Switched to mode: Auto"),
|
|
"after Plan → Auto cycle, screen must show Auto (distinct mode). screen=\n{screen}"
|
|
);
|
|
if screen.contains("Always-Approve") && !screen.contains("Auto") {
|
|
panic!("landed on Always-Approve without Auto — cycle skipped Auto mode");
|
|
}
|
|
|
|
let _ = harness.inject_keys(b"q");
|
|
}
|
|
|
|
/// Gate OFF (the shipped default): the Shift+Tab ring must SKIP Auto entirely
|
|
/// (Normal → Plan → Always-Approve → Normal), so the feature is inert and the
|
|
/// classifier never launches. Negative companion to the gate-ON cycle test;
|
|
/// proves the gate governs the UI ring, not just the engine.
|
|
#[test]
|
|
#[ignore = "spawns real pager PTY; run with cargo test -- --ignored"]
|
|
fn pty_shift_tab_skips_auto_when_gate_off() {
|
|
let binary = match pager_binary() {
|
|
Ok(b) => b,
|
|
Err(e) => panic!("resolve pager binary via harness env: {e:#}"),
|
|
};
|
|
let tmp = tempfile::tempdir().expect("temp HOME");
|
|
let env_owned = prepare_sandbox(tmp.path(), false);
|
|
let env_refs: Vec<(&str, &str)> = env_owned
|
|
.iter()
|
|
.map(|(k, v)| (k.as_str(), v.as_str()))
|
|
.collect();
|
|
|
|
let mut harness = PtyHarness::new(&binary, ROWS, COLS, &[], &env_refs)
|
|
.expect("spawn pager in PTY (kigi-pager-pty-harness)");
|
|
|
|
let _ = harness.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT);
|
|
let early = harness.screen_contents();
|
|
if is_login_screen(&early) {
|
|
assert!(
|
|
!require_auth(),
|
|
"KIGI_PTY_REQUIRE_AUTH set but pager hit the login screen — auth seeding broke"
|
|
);
|
|
eprintln!(
|
|
"pty_auto_mode(gate off): login/device-auth screen blocked cycle; env auth limit"
|
|
);
|
|
assert!(
|
|
harness.is_running() || !early.is_empty(),
|
|
"pager must have produced output even on login screen"
|
|
);
|
|
let _ = harness.inject_keys(b"\x11");
|
|
return;
|
|
}
|
|
|
|
// Cycle the full ring (4 presses returns to Normal). With the gate off the
|
|
// ring is Normal → Plan → Always-Approve → Normal: Auto must never appear,
|
|
// while Plan and Always-Approve still must (the ring otherwise works).
|
|
let mut saw_plan = false;
|
|
let mut saw_always = false;
|
|
let mut saw_auto = false;
|
|
for _ in 0..4 {
|
|
harness.inject_keys(SHIFT_TAB).expect("inject Shift+Tab");
|
|
// Drain output so the NEW mode banner renders before we read. A
|
|
// `wait_for_text("Switched to mode:")` would hit its fast-path and
|
|
// return instantly on the prior press's banner still on screen, so we
|
|
// explicitly pump for a fixed window instead (the screen tracker keeps
|
|
// only the latest frame, so each read reflects the current mode).
|
|
harness.update(Duration::from_secs(2));
|
|
let s = harness.screen_contents();
|
|
if is_login_screen(&s) {
|
|
eprintln!("pty_auto_mode(gate off): landed on login after key inject; env auth limit");
|
|
return;
|
|
}
|
|
if s.contains("Switched to mode: Plan") {
|
|
saw_plan = true;
|
|
}
|
|
if s.contains("Switched to mode: Always-Approve") {
|
|
saw_always = true;
|
|
}
|
|
if s.contains("Switched to mode: Auto") {
|
|
saw_auto = true;
|
|
}
|
|
}
|
|
|
|
if let Ok(dump) = std::env::var("PTY_AUTO_MODE_SCREEN_DUMP") {
|
|
let _ = std::fs::write(&dump, harness.screen_contents());
|
|
}
|
|
|
|
assert!(
|
|
!saw_auto,
|
|
"gate OFF: Shift+Tab ring must skip Auto, but the Auto banner appeared"
|
|
);
|
|
assert!(
|
|
saw_plan && saw_always,
|
|
"gate OFF: ring should still cycle Plan and Always-Approve \
|
|
(saw_plan={saw_plan}, saw_always={saw_always})"
|
|
);
|
|
|
|
let _ = harness.inject_keys(b"q");
|
|
}
|
|
|
|
/// Structural: harness crate exports used by this e2e (fails compile if removed).
|
|
#[test]
|
|
fn pty_harness_api_surface_for_auto_mode_e2e() {
|
|
let _ = std::any::type_name::<PtyHarness>();
|
|
assert_eq!(SHIFT_TAB, b"\x1b[Z");
|
|
}
|