The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok' crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party license archives, README provenance, and the required 'Based on Grok Build Open Source' attribution, now sourced from version_attribution.txt). Wire-visible renames (both sides in this repo, changed in lockstep): - Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode). - Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* / _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps a read-side alias for the legacy '_x.ai/session/update' method so existing updates.jsonl histories load; writes emit only the new name (both directions test-pinned). - Agent types grok-build* → kigi* with a documented legacy-prefix alias at resolution time so persisted sessions keep resolving. - ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build implementation dirs renamed to kigi*. - x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes grokday/groknight → kigiday/kiginight (old persisted values fall back to the default theme), web_fetch allowlist xAI hosts → kimi.com + moonshot platforms, changelog CDN → this repo, grok-build changelog archives deleted. - BYOK default endpoint removed: [endpoints] api_base_url is now truly optional with NO default — consumers fail fast with the flag name when unset (no silent x.ai egress). Mock harnesses inject it explicitly. - System-prompt identity fixed: 'released by xAI' → 'an unofficial community CLI for Kimi' (template + regenerated encrypted form). Also repaired pre-existing grok-era test debt found by the sweep: the stale trace_classify default-model pin, the grok-pager UA label test, pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY test could previously reach the real api.moonshot.cn), and the outdated oauth fixture scope key. Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings); FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed; deny advisories ok.
223 lines
7.0 KiB
Rust
223 lines
7.0 KiB
Rust
//! Unit tests for the `kigi wrap` spawn planning in [`super`] (`wrap_cmd`),
|
|
//! split out via `#[path]` to keep the module itself small.
|
|
//!
|
|
//! Everything is pure — `derive_spawn` takes the PATH lookup result and the
|
|
//! shell as inputs — except the final test, which round-trips the rejoined
|
|
//! command line through a real `/bin/sh -c` to prove the quoting contract end
|
|
//! to end.
|
|
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
fn cmd(words: &[&str]) -> Vec<String> {
|
|
words.iter().map(|w| w.to_string()).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn single_arg_with_whitespace_always_routes_via_shell() {
|
|
// Wins over PATH resolution: the arg is a command line, not a program.
|
|
let plan = derive_spawn(
|
|
&cmd(&["mycli ssh host-01"]),
|
|
"/bin/zsh",
|
|
true,
|
|
ShellMode::Interactive,
|
|
);
|
|
assert_eq!(plan.program, "/bin/zsh");
|
|
assert_eq!(plan.args, cmd(&["-i", "-c", "mycli ssh host-01"]));
|
|
|
|
// Wins over the explicit-path rule too: a single program path with
|
|
// spaces word-splits in the shell (quote it for the shell instead).
|
|
let plan = derive_spawn(
|
|
&cmd(&["/path/with space/prog"]),
|
|
"/bin/zsh",
|
|
false,
|
|
ShellMode::Interactive,
|
|
);
|
|
assert_eq!(plan.args, cmd(&["-i", "-c", "/path/with space/prog"]));
|
|
|
|
// Any whitespace counts, not just spaces: tabs/newlines also mark the
|
|
// arg as a command line to hand to the shell verbatim.
|
|
let plan = derive_spawn(
|
|
&cmd(&["x\tssh\nhost"]),
|
|
"/bin/sh",
|
|
false,
|
|
ShellMode::Interactive,
|
|
);
|
|
assert_eq!(plan.program, "/bin/sh");
|
|
assert_eq!(plan.args, cmd(&["-i", "-c", "x\tssh\nhost"]));
|
|
}
|
|
|
|
#[test]
|
|
fn resolvable_program_spawns_directly() {
|
|
let plan = derive_spawn(
|
|
&cmd(&["explorer", "ssh", "host"]),
|
|
"/bin/zsh",
|
|
true,
|
|
ShellMode::Interactive,
|
|
);
|
|
assert_eq!(plan.program, "explorer");
|
|
assert_eq!(plan.args, cmd(&["ssh", "host"]));
|
|
|
|
// A single resolvable word is a plain program run, not an alias.
|
|
let plan = derive_spawn(&cmd(&["ls"]), "/bin/zsh", true, ShellMode::Interactive);
|
|
assert_eq!(plan.program, "ls");
|
|
assert_eq!(plan.args, Vec::<String>::new());
|
|
|
|
// Explicit paths never route through the shell, resolvable or not —
|
|
// relative or absolute.
|
|
let plan = derive_spawn(
|
|
&cmd(&["./run.sh", "arg"]),
|
|
"/bin/zsh",
|
|
false,
|
|
ShellMode::Interactive,
|
|
);
|
|
assert_eq!(plan.program, "./run.sh");
|
|
let plan = derive_spawn(
|
|
&cmd(&["/abs/prog", "arg"]),
|
|
"/bin/zsh",
|
|
false,
|
|
ShellMode::Interactive,
|
|
);
|
|
assert_eq!(plan.program, "/abs/prog");
|
|
|
|
// A multi-arg first word with whitespace is never an alias: keep the
|
|
// direct spawn and its precise not-found error.
|
|
let plan = derive_spawn(
|
|
&cmd(&["my prog", "arg"]),
|
|
"/bin/zsh",
|
|
false,
|
|
ShellMode::Interactive,
|
|
);
|
|
assert_eq!(plan.program, "my prog");
|
|
assert_eq!(plan.args, cmd(&["arg"]));
|
|
|
|
// Same for an empty first word (unset `$PROG`): fail fast on the
|
|
// direct spawn rather than shell-executing the tail.
|
|
let plan = derive_spawn(
|
|
&cmd(&["", "rm", "-rf", "x"]),
|
|
"/bin/zsh",
|
|
false,
|
|
ShellMode::Interactive,
|
|
);
|
|
assert_eq!(plan.program, "");
|
|
}
|
|
|
|
#[test]
|
|
fn direct_route_passes_args_verbatim_without_quoting() {
|
|
// Quoting exists only on the shell route's rejoined line; a resolvable
|
|
// program must receive its argv elements byte-for-byte.
|
|
let plan = derive_spawn(
|
|
&cmd(&["rsync", "a b", "don't"]),
|
|
"/bin/zsh",
|
|
true,
|
|
ShellMode::Interactive,
|
|
);
|
|
assert_eq!(plan.program, "rsync");
|
|
assert_eq!(plan.args, cmd(&["a b", "don't"]));
|
|
}
|
|
|
|
#[test]
|
|
fn alias_route_keeps_first_word_bare_and_quotes_the_tail() {
|
|
// First word stays unquoted (aliases only expand on bare words); every
|
|
// tail word is quoted — zsh expands a bare `=word`, so no safe set.
|
|
let plan = derive_spawn(
|
|
&cmd(&["x", "ssh", "=host", "don't"]),
|
|
"/bin/zsh",
|
|
false,
|
|
ShellMode::Interactive,
|
|
);
|
|
assert_eq!(plan.program, "/bin/zsh");
|
|
assert_eq!(plan.args, cmd(&["-i", "-c", "x 'ssh' '=host' 'don'\\''t'"]));
|
|
|
|
// Bare alias with no args: the most common invocation.
|
|
let plan = derive_spawn(&cmd(&["x"]), "/bin/zsh", false, ShellMode::Interactive);
|
|
assert_eq!(plan.args, cmd(&["-i", "-c", "x"]));
|
|
}
|
|
|
|
#[test]
|
|
fn plain_shell_mode_drops_dash_i() {
|
|
let plan = derive_spawn(
|
|
&cmd(&["mycli ssh host"]),
|
|
"/bin/sh",
|
|
false,
|
|
ShellMode::Plain,
|
|
);
|
|
assert_eq!(plan.program, "/bin/sh");
|
|
assert_eq!(plan.args, cmd(&["-c", "mycli ssh host"]));
|
|
}
|
|
|
|
#[test]
|
|
fn quote_word_edge_cases() {
|
|
assert_eq!(quote_word("don't"), "'don'\\''t'");
|
|
assert_eq!(quote_word(""), "''");
|
|
// A lone quote is the worst case for the close-escape-reopen idiom.
|
|
assert_eq!(quote_word("'"), "''\\'''");
|
|
}
|
|
|
|
#[test]
|
|
fn quote_word_neutralizes_shell_metacharacters() {
|
|
// Expansion/control characters must come out single-quoted inert.
|
|
for meta in ["$HOME", "`id`", "*", "!!", ";", "&&", "|", "a\nb"] {
|
|
assert_eq!(quote_word(meta), format!("'{meta}'"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn join_command_line_shapes() {
|
|
// Zero tail words: just the bare first word, no trailing space.
|
|
assert_eq!(join_command_line(&cmd(&["x"])), "x");
|
|
// An empty tail word survives as an explicit empty argument.
|
|
assert_eq!(join_command_line(&cmd(&["x", ""])), "x ''");
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_shell_falls_back_to_bin_sh() {
|
|
assert_eq!(resolve_shell(None), "/bin/sh");
|
|
assert_eq!(resolve_shell(Some("")), "/bin/sh");
|
|
assert_eq!(resolve_shell(Some("/nonexistent/shell-xyz")), "/bin/sh");
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_shell_uses_existing_file() {
|
|
let file = tempfile::NamedTempFile::new().expect("tempfile");
|
|
let path = file.path().to_str().expect("utf8 path").to_string();
|
|
assert_eq!(resolve_shell(Some(&path)), path);
|
|
}
|
|
|
|
/// Feed the rejoined command line through a real `/bin/sh -c` and assert the
|
|
/// child receives exactly the original words — the quoting contract proven
|
|
/// against an actual shell, not just against expected strings.
|
|
#[test]
|
|
fn joined_line_roundtrips_words_through_real_sh() {
|
|
let words = [
|
|
"a b",
|
|
"don't",
|
|
"=x",
|
|
"new\nline",
|
|
"$HOME",
|
|
"`id`",
|
|
"*",
|
|
";",
|
|
"&&",
|
|
"|",
|
|
"",
|
|
];
|
|
// `printf` repeats the format per argument, so each word renders as `[w]`.
|
|
let mut command = vec!["printf".to_string(), "[%s]".to_string()];
|
|
command.extend(words.iter().map(|w| w.to_string()));
|
|
|
|
let out = std::process::Command::new("/bin/sh")
|
|
.arg("-c")
|
|
.arg(join_command_line(&command))
|
|
.output()
|
|
.expect("run /bin/sh");
|
|
|
|
assert!(
|
|
out.status.success(),
|
|
"sh rejected the joined line: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
let expected: String = words.iter().map(|w| format!("[{w}]")).collect();
|
|
assert_eq!(String::from_utf8_lossy(&out.stdout), expected);
|
|
}
|