Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions desktop/src-tauri/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -380,11 +380,13 @@ fn where_installers_put(engine: Engine) -> Option<PathBuf> {
let places: Vec<PathBuf> = match engine {
#[cfg(target_os = "windows")]
Engine::Podman => {
// The per-user MSI first: it is the one OpenBot runs. A machine-wide install left by
// somebody else is still found by the second.
// The MSI uses Programs\Podman per user and Program Files\Podman per machine.
// Keep the legacy RedHat location for installations from the older EXE installer.
[
std::env::var_os("LOCALAPPDATA")
.map(|local| PathBuf::from(local).join("Programs\\Podman\\podman.exe")),
std::env::var_os("ProgramFiles")
.map(|files| PathBuf::from(files).join("Podman\\podman.exe")),
std::env::var_os("ProgramFiles")
.map(|files| PathBuf::from(files).join("RedHat\\Podman\\podman.exe")),
]
Expand Down Expand Up @@ -613,6 +615,48 @@ mod tests {
assert_eq!(on_path("openbot-not-a-real-binary"), None);
}

#[test]
#[cfg(windows)]
fn windows_installed_podman_runs_without_an_inherited_path_entry() {
if crate::test_support::isolated_process(
"engine::tests::windows_installed_podman_runs_without_an_inherited_path_entry",
) {
return;
}
let root = crate::test_support::temp_root("podman install locations");
std::fs::create_dir_all(&root).unwrap();
let source = root.join("engine.rs");
let binary = root.join("fixture.exe");
std::fs::write(&source, "fn main() { println!(\"1.44\"); }").unwrap();
crate::test_support::compile_fixture(&source, &binary);
let local = root.join("Local");
let program_files = root.join("Program Files");
std::env::set_var("LOCALAPPDATA", &local);
std::env::set_var("ProgramFiles", &program_files);
std::env::set_var("PATH", root.join("empty-path"));

for installation in [
local.join("Programs/Podman/podman.exe"),
program_files.join("Podman/podman.exe"),
program_files.join("RedHat/Podman/podman.exe"),
] {
std::fs::create_dir_all(installation.parent().unwrap()).unwrap();
std::fs::copy(&binary, &installation).unwrap();
assert_eq!(program(Engine::Podman).as_ref(), Some(&installation));
let address = Address::new(Engine::Podman, Some("openbot".into()));
assert_eq!(address.parts().0, installation);
assert!(address.responds(), "resolved engine should actually run");
assert!(tool(Engine::Podman)
.arg("--version")
.output()
.unwrap()
.status
.success());
std::fs::remove_file(installation).unwrap();
}
std::fs::remove_dir_all(root).unwrap();
}

/// The provider directory has to be in *front* of PATH: a broken `docker-compose` earlier on
/// somebody's PATH would otherwise be the one Podman runs.
#[test]
Expand Down
77 changes: 75 additions & 2 deletions desktop/src-tauri/src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -363,13 +363,25 @@ pub fn install_engine(cache: &Path) -> Result<String, Problem> {

/// Observe an actual Podman installer invocation, excluding existing engines and Compose repair.
pub fn install_engine_observed(
cache: &Path,
installed: impl FnMut(bool),
) -> Result<String, Problem> {
install_engine_with(cache, installed, install_podman, place_compose)
}

fn install_engine_with(
cache: &Path,
mut installed: impl FnMut(bool),
install_podman: impl FnOnce(&Path) -> Result<(), Problem>,
place_compose: impl FnOnce(&Path) -> Result<String, Problem>,
) -> Result<String, Problem> {
let into = crate::acquire::download_dir(cache);

// An engine somebody already has is theirs. This only ever adds what is missing.
if engine::program(Engine::Docker).is_some() || engine::program(Engine::Podman).is_some() {
// engine_ready can start an installed Podman machine, but cannot start Docker. A stopped
// Docker CLI must not skip installing the Podman that preparation will then try to run.
if engine::program(Engine::Podman).is_some()
|| engine::Address::new(Engine::Docker, None).responds()
{
return place_compose(&into);
}

Expand Down Expand Up @@ -642,6 +654,67 @@ mod tests {
use super::*;
use crate::test_support::temp_root;

#[test]
#[cfg(windows)]
fn a_stopped_docker_does_not_skip_the_podman_needed_for_setup() {
if crate::test_support::isolated_process(
"install::tests::a_stopped_docker_does_not_skip_the_podman_needed_for_setup",
) {
return;
}
let root = temp_root("stopped-docker-install");
let bin = root.join("bin");
std::fs::create_dir_all(&bin).unwrap();
let source = root.join("engine.rs");
std::fs::write(
&source,
r#"fn main() {
if std::env::var_os("OPENBOT_TEST_DOCKER_RUNNING").is_none() {
std::process::exit(1);
}
println!("1.44");
}"#,
)
.unwrap();
crate::test_support::compile_fixture(&source, &bin.join("docker.exe"));
std::env::set_var("PATH", &bin);
std::env::set_var("LOCALAPPDATA", root.join("Local"));
std::env::set_var("ProgramFiles", root.join("Program Files"));
std::env::remove_var("OPENBOT_TEST_DOCKER_RUNNING");

let found = engine::detect();
assert_eq!(found.engine, Some(Engine::Docker));
assert!(!found.responding);
assert!(engine::program(Engine::Podman).is_none());
let mut observed = Vec::new();
let result = install_engine_with(
&root,
|success| observed.push(success),
|_| {
// Installation supplies the engine that engine_ready will create/start.
std::fs::copy(bin.join("docker.exe"), bin.join("podman.exe")).unwrap();
Ok(())
},
|_| Ok("Compose placed".into()),
);
result.expect("setup should install its missing Podman");
assert_eq!(observed, [true], "the Podman installer must run");
assert!(engine::program(Engine::Podman).is_some());

// A responding Docker still supplies the engine; never install a replacement.
std::fs::remove_file(bin.join("podman.exe")).unwrap();
std::env::set_var("OPENBOT_TEST_DOCKER_RUNNING", "1");
assert!(engine::detect().responding);
let result = install_engine_with(
&root,
|_| panic!("no Podman installation should be observed"),
|_| panic!("responding Docker must be reused"),
|_| Ok("Compose placed".into()),
);
assert_eq!(result.unwrap(), "Compose placed");
std::fs::remove_dir_all(root).unwrap();
}

/// Every platform this app runs on has a Compose build, or the stack cannot be raised there.
#[test]
fn this_platform_has_a_compose_build() {
Expand Down
176 changes: 165 additions & 11 deletions desktop/src-tauri/src/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,22 @@ breaks the first time a hint or a colour is added, and breaking here means telli
approved in their browser that it failed.
*/
pub fn token_in(output: &str) -> Option<String> {
plain(output)
.split(|c: char| c.is_whitespace() || c == '"' || c == '\'')
.map(|word| word.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_'))
.find(|word| word.starts_with(PLAN_TOKEN_PREFIX) && word.len() > 30)
fn token_character(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '-' || c == '_'
}

let text = plain(output);
// Cursor positioning can separate a label from its token visually without a space
// in the stream. Accept the prefix after punctuation, but not inside another word.
text.match_indices(PLAN_TOKEN_PREFIX)
.filter(|(at, _)| !matches!(text[..*at].chars().next_back(), Some(c) if token_character(c)))
.map(|(at, _)| {
text[at..]
.split(|c| !token_character(c))
.next()
.unwrap_or("")
})
.find(|token| token.len() > 30)
.map(str::to_string)
}

Expand All @@ -202,6 +214,12 @@ pub struct SigningIn {
child: Box<dyn portable_pty::Child + Send + Sync>,
writer: Box<dyn std::io::Write + Send>,
output: std::sync::Arc<std::sync::Mutex<String>>,
// ConPTY's pipe clones do not own its console. Dropping the last master closes the
// console and its child, so retain it through the code/token exchange on Windows.
#[cfg(windows)]
_master: Box<dyn portable_pty::MasterPty + Send>,
#[cfg(windows)]
cursor_reported: bool,
}

/// How long to wait for the CLI to show the URL. Machine time: a container start and an HTTP call.
Expand Down Expand Up @@ -298,6 +316,10 @@ impl SigningIn {
child,
writer,
output,
#[cfg(windows)]
_master: pty.master,
#[cfg(windows)]
cursor_reported: false,
};
let url = signing
.wait_for(authorize_url_in, PATIENCE_FOR_THE_LINK)
Expand Down Expand Up @@ -387,6 +409,16 @@ impl SigningIn {
let began = Instant::now();
while began.elapsed() < patience {
if let Ok(seen) = self.output.lock() {
// portable-pty enables ConPTY's INHERIT_CURSOR flag. It waits for this
// reply before emitting the child's output, and can hang on close without it.
// This hidden terminal starts at 1;1. Accumulating output also handles a
// query split across reads; reply once to the initial inheritance request.
#[cfg(windows)]
if !self.cursor_reported && seen.contains("\x1b[6n") {
self.writer.write_all(b"\x1b[1;1R").ok()?;
self.writer.flush().ok()?;
self.cursor_reported = true;
}
if let Some(value) = found(&seen) {
return Some(value);
}
Expand Down Expand Up @@ -451,6 +483,27 @@ const CHATGPT_STORE: &str = "/root/.langchain/chatgpt-auth.json";
const CHATGPT_LOOPBACK: u16 = 1455;
const CHATGPT_RELAY: u16 = 1456;

fn publish_chatgpt_callback(
command: &mut std::process::Command,
engine: crate::engine::Engine,
windows: bool,
) {
// Windows Podman's IPv6 forward accepts TCP but closes the callback without an HTTP
// response. That prevents localhost clients from trying the working IPv4 address:
// Happy Eyeballs stops at the first successful TCP handshake (RFC 8305, sections 5/9.2).
// Publish only IPv4 there so localhost falls back after IPv6 connection refusal.
// Keep the registered localhost redirect URI and the other runtimes' bindings unchanged.
let hosts: &[&str] = if windows && engine == crate::engine::Engine::Podman {
&["127.0.0.1"]
} else {
&["127.0.0.1", "[::1]"]
};
for host in hosts {
command.arg("-p");
command.arg(format!("{host}:{CHATGPT_LOOPBACK}:{CHATGPT_RELAY}"));
}
}

/**
The ChatGPT sign-in, as a program handed to the harness image.

Expand Down Expand Up @@ -567,14 +620,10 @@ impl SigningInToChatGpt {
* Published on loopback only, and on the number the vendor's login advertises.
*
* The container's relay listens on `CHATGPT_RELAY` and forwards to the login's own
* loopback bind; the browser is sent to `CHATGPT_LOOPBACK` on this machine. Both families
* are published because a browser resolving the registered `localhost` may pick either, and
* which one it picks is not ours to decide.
* loopback bind; the browser is sent to `CHATGPT_LOOPBACK` on this machine.
* publish_chatgpt_callback handles the Windows Podman IPv6 forwarding limitation.
*/
for host in ["127.0.0.1", "[::1]"] {
command.arg("-p");
command.arg(format!("{host}:{CHATGPT_LOOPBACK}:{CHATGPT_RELAY}"));
}
publish_chatgpt_callback(&mut command, engine.engine, cfg!(windows));
command.arg(image);
command.arg("python");
command.arg("-u");
Expand Down Expand Up @@ -721,6 +770,101 @@ pub fn openai_url_in(output: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::Engine;

#[test]
#[cfg(windows)]
fn windows_claude_sign_in_keeps_its_terminal_until_the_flow_finishes() {
if crate::test_support::isolated_process(
"plan::tests::windows_claude_sign_in_keeps_its_terminal_until_the_flow_finishes",
) {
return;
}
let root = crate::test_support::temp_root("claude-terminal-lifetime");
std::fs::create_dir_all(&root).unwrap();
let source = root.join("podman.rs");
std::fs::write(
&source,
r#"use std::io::Write;
fn main() {
print!("\x1b]8;;https://claude.ai/oauth/authorize?synthetic=terminal-lifetime\x1b\\Sign in\x1b]8;;\x1b\\\r\n");
println!("Paste code here if prompted");
std::io::stdout().flush().unwrap();
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
assert_eq!(input.trim(), format!("{}#{}", "c".repeat(43), "s".repeat(48)));
print!("Your OAuth token (valid for 1 year):");
std::io::stdout().flush().unwrap();
std::thread::sleep(std::time::Duration::from_millis(60));
println!("\x1b[40G\x1b[32msk-ant-oat01-{}\x1b[0m", "s".repeat(95));
}"#,
)
.unwrap();
crate::test_support::compile_fixture(&source, &root.join("podman.exe"));
std::env::set_var("PATH", &root);

// Bound begin and cleanup, including any destructor run before either returns.
// The fixture prints a synthetic URL and waits; no provider or container is contacted.
let (sent, received) = std::sync::mpsc::channel();
let worker = std::thread::spawn(move || {
let result = (|| {
let (mut signing, url) = SigningIn::begin(
&crate::engine::Address::new(Engine::Podman, None),
"synthetic-sign-in-image",
)?;
assert_eq!(
url,
"https://claude.ai/oauth/authorize?synthetic=terminal-lifetime"
);
assert!(
signing.child.try_wait().unwrap().is_none(),
"the login child must survive until the code can be supplied"
);
let draining = std::sync::Arc::downgrade(&signing.output);
let code = format!("{}#{}", "c".repeat(43), "s".repeat(48));
assert_eq!(
signing.finish(&code)?,
format!("sk-ant-oat01-{}", "s".repeat(95))
);
// Modern ClosePseudoConsole returns before its clients disconnect. The
// reader's EOF, not the master's drop, marks completed console cleanup.
// Keep this inside the deadline before deleting the fixture executable.
while draining.strong_count() != 0 {
std::thread::sleep(Duration::from_millis(10));
}
Ok::<_, String>(())
})();
let _ = sent.send(result);
});
received
.recv_timeout(Duration::from_secs(10))
.expect("begin and cleanup must finish without a terminal teardown deadlock")
.expect("the synthetic login should provide a URL");
worker.join().unwrap();
std::fs::remove_dir_all(root).unwrap();
}

#[test]
fn chatgpt_callback_uses_ipv4_only_for_windows_podman() {
for (engine, windows, hosts) in [
(Engine::Podman, true, vec!["127.0.0.1"]),
(Engine::Podman, false, vec!["127.0.0.1", "[::1]"]),
(Engine::Docker, true, vec!["127.0.0.1", "[::1]"]),
(Engine::Docker, false, vec!["127.0.0.1", "[::1]"]),
] {
let mut command = crate::quiet::command(engine.binary());
publish_chatgpt_callback(&mut command, engine, windows);
let args: Vec<_> = command
.get_args()
.map(|arg| arg.to_str().unwrap())
.collect();
let expected: Vec<String> = hosts
.into_iter()
.flat_map(|host| ["-p".into(), format!("{host}:1455:1456")])
.collect();
assert_eq!(args, expected, "{engine:?}, Windows={windows}");
}
}

/// Fixtures are composed from the prefix rather than written out, so no credential-shaped
/// literal sits in this repository for a scanner to find or a person to copy.
Expand Down Expand Up @@ -911,6 +1055,16 @@ mod tests {
assert_eq!(authorize_url_in(""), None);
}

#[test]
fn token_after_a_cursor_positioned_label_is_found_in_full() {
let token = format!("{PLAN_TOKEN_PREFIX}01-{}", "s".repeat(95));
let output = format!(
"Your OAuth token (valid for 1 year):\x1b[1G\x1b[32m{token}\x1b[0m\nStore this token safely."
);
assert_eq!(token_in(&output), Some(token.clone()));
assert_eq!(token_in(&format!("other-{token}")), None);
}

/// The stripper has to survive what a TUI actually emits, including a bare ESC pair.
#[test]
fn escapes_come_out_and_the_words_stay() {
Expand Down
Loading