diff --git a/app/src-tauri/src/auth_bridge/mod.rs b/app/src-tauri/src/auth_bridge/mod.rs index 88a0824..cd55876 100644 --- a/app/src-tauri/src/auth_bridge/mod.rs +++ b/app/src-tauri/src/auth_bridge/mod.rs @@ -32,7 +32,7 @@ //! |---|---| //! | Bridge disabled | `set_auth_bridge_enabled(false)` → [`AuthBridgeManager::stop`] | //! | Container stopped via UI | `stop_project_container` → [`AuthBridgeManager::stop`] | -//! | Container stopped/died another way | poller's own `is_container_running` check → loop exits | +//! | Container stopped/died another way | poller's own `is_running` check → loop exits | //! | Project deleted | `remove_project` → [`AuthBridgeManager::stop`]; also the poller's `store.get()` check | //! | Container rebuilt | `rebuild_project_container` → stop, then start re-arms it | //! | App exit | window `CloseRequested` → [`AuthBridgeManager::stop_all`] | @@ -55,8 +55,7 @@ use tauri::{AppHandle, Emitter}; use tokio::sync::{watch, Mutex}; use tokio::task::JoinHandle; -use crate::docker::container::is_container_running; -use crate::docker::exec::{exec_oneshot_limited, PROC_NET_OUTPUT_LIMIT}; +use crate::host::{ContainerTarget, PROC_NET_OUTPUT_LIMIT}; use crate::storage::projects_store::ProjectsStore; use proc_net::PortFamily; @@ -205,7 +204,7 @@ impl AuthBridgeManager { let poller = tokio::spawn(poll_loop( project_id.clone(), - container_id, + ContainerTarget::docker(container_id), epoch, app, store, @@ -276,7 +275,7 @@ impl AuthBridgeManager { #[allow(clippy::too_many_arguments)] async fn poll_loop( project_id: String, - container_id: String, + container: ContainerTarget, epoch: u64, app: AppHandle, store: Arc, @@ -301,7 +300,7 @@ async fn poll_loop( log::info!("Auth bridge: disabled for project {} — tearing down", project_id); break; } - if !is_container_running(&container_id).await.unwrap_or(false) { + if !container.is_running().await.unwrap_or(false) { log::info!( "Auth bridge: container for project {} is no longer running — tearing down", project_id @@ -327,7 +326,7 @@ async fn poll_loop( // bridge or stopping the container doesn't wait out an in-flight poll. let discovery = tokio::select! { _ = cancel.changed() => break, - res = exec_oneshot_limited(&container_id, "claude", cmd, PROC_NET_OUTPUT_LIMIT) => { + res = container.exec_oneshot_limited("claude", cmd, PROC_NET_OUTPUT_LIMIT) => { res.map(|(out, _)| out) } }; @@ -340,7 +339,7 @@ async fn poll_loop( // gateway/STT/web-terminal ports can be re-pointed while the // bridge is running, and a stale reservation set is a hole. let skip = skipped_ports(&project, &store.list(), &app_settings(&app)); - if reconcile(&container_id, &discovered, &skip, &state).await { + if reconcile(&container, &discovered, &skip, &state).await { emit_status(&app, &project_id, &state, true).await; } } @@ -530,7 +529,7 @@ const MAX_CONFLICTS: usize = 32; /// Bring the set of host listeners in line with what the container is currently /// listening on. Returns whether anything the UI cares about changed. async fn reconcile( - container_id: &str, + container: &ContainerTarget, discovered: &BTreeMap, skip: &HashSet, state: &Arc>, @@ -583,7 +582,7 @@ async fn reconcile( ); continue; } - match PortForward::bind(container_id.to_string(), port, family).await { + match PortForward::bind(container.clone(), port, family).await { Ok(forward) => { if st.conflicts.remove(&port).is_some() { log::info!("Auth bridge: host port {} became available", port); @@ -833,7 +832,13 @@ mod tests { (45000u16..45200).map(|p| (p, PortFamily::V4)).collect(); let state = Arc::new(Mutex::new(BridgeState::default())); - reconcile("no-such-container", &discovered, &HashSet::new(), &state).await; + reconcile( + &ContainerTarget::docker("no-such-container"), + &discovered, + &HashSet::new(), + &state, + ) + .await; let mut st = state.lock().await; assert!( diff --git a/app/src-tauri/src/auth_bridge/tunnel.rs b/app/src-tauri/src/auth_bridge/tunnel.rs index 9f353c5..d40588d 100644 --- a/app/src-tauri/src/auth_bridge/tunnel.rs +++ b/app/src-tauri/src/auth_bridge/tunnel.rs @@ -11,7 +11,7 @@ //! and stdout wired to the TCP socket. `socat` ships in the container image. //! //! The exec plumbing itself is *not* reimplemented here: it comes from -//! [`crate::docker::exec::create_attached_exec`], the same helper the +//! [`crate::host::ContainerTarget::attach_exec`], the same helper the //! interactive terminal sessions are built on. //! //! ## What the host listener is, and is not @@ -56,14 +56,12 @@ use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; use std::time::Duration; -use crate::host::ExecChunk; +use crate::host::{AttachedExec, ContainerTarget, ExecChunk}; use futures_util::StreamExt; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::task::{JoinHandle, JoinSet}; -use crate::docker::exec::{create_attached_exec, AttachedExec}; - use super::proc_net::PortFamily; /// Buffer size for the host→container direction. OAuth callbacks are tiny; this @@ -143,13 +141,13 @@ impl Drop for PortForward { } impl PortForward { - /// Bind `port` on the host loopback and start proxying into `container_id`. + /// Bind `port` on the host loopback and start proxying into `container`. /// /// The bind happens before the task is spawned, so an already-taken port is /// reported to the caller as an error rather than disappearing into a /// background task. pub async fn bind( - container_id: String, + container: ContainerTarget, port: u16, family: PortFamily, ) -> Result { @@ -196,7 +194,7 @@ impl PortForward { }; let target = family.socat_target(port); - let task = tokio::spawn(accept_loop(container_id, port, target, v4, v6)); + let task = tokio::spawn(accept_loop(container, port, target, v4, v6)); Ok(Self { port, @@ -219,7 +217,7 @@ impl PortForward { /// Accept on both loopback listeners until aborted. Dropping this future drops /// the listeners (freeing the port) and the `JoinSet` (aborting live tunnels). async fn accept_loop( - container_id: String, + container: ContainerTarget, port: u16, target: String, v4: TcpListener, @@ -258,7 +256,7 @@ async fn accept_loop( log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port); let _ = stream.set_nodelay(true); conns.spawn(tunnel_connection( - container_id.clone(), + container.clone(), target.clone(), stream, port, @@ -288,7 +286,12 @@ async fn accept_optional( /// /// Nothing is forwarded until that decision is made, so a refused request never /// reaches the container at all — not even a `docker exec`. -async fn tunnel_connection(container_id: String, target: String, mut stream: TcpStream, port: u16) { +async fn tunnel_connection( + container: ContainerTarget, + target: String, + mut stream: TcpStream, + port: u16, +) { let head = match read_leading_bytes(&mut stream).await { Ok(head) => head, Err(e) => { @@ -319,7 +322,7 @@ async fn tunnel_connection(container_id: String, target: String, mut stream: Tcp } // The bytes already off the socket go back on the wire first, byte-exact. - tunnel_connection_with_prelude(container_id, target, stream, port, head.into_buffer()).await + tunnel_into_with_prelude(container, target, stream, port, head.into_buffer()).await } /// What the first bytes of an accepted connection turned out to be. @@ -511,12 +514,34 @@ pub(crate) fn web_request_verdict(head: &str) -> Verdict { /// head off the socket to check a token, and then has to put those same bytes /// back on the wire. Passing them here keeps the byte stream exact, rather than /// re-serialising a parsed request. +/// +/// Takes a Docker container id because that is what the browser-view proxy +/// holds; [`tunnel_into_with_prelude`] is the same thing for a +/// [`ContainerTarget`] already in hand. pub async fn tunnel_connection_with_prelude( container_id: String, target: String, stream: TcpStream, port: u16, prelude: Vec, +) { + tunnel_into_with_prelude( + ContainerTarget::docker(container_id), + target, + stream, + port, + prelude, + ) + .await +} + +/// [`tunnel_connection_with_prelude`] for a [`ContainerTarget`]. +pub async fn tunnel_into_with_prelude( + container: ContainerTarget, + target: String, + stream: TcpStream, + port: u16, + prelude: Vec, ) { let cmd = vec!["socat".to_string(), "-".to_string(), target.clone()]; @@ -524,7 +549,7 @@ pub async fn tunnel_connection_with_prelude( mut output, mut input, .. - } = match create_attached_exec(&container_id, cmd, false).await { + } = match container.attach_exec(cmd, false).await { Ok(e) => e, Err(e) => { log::warn!( diff --git a/app/src-tauri/src/browser_view/detect.rs b/app/src-tauri/src/browser_view/detect.rs index 241eac9..eb88a06 100644 --- a/app/src-tauri/src/browser_view/detect.rs +++ b/app/src-tauri/src/browser_view/detect.rs @@ -36,7 +36,7 @@ use serde::{Deserialize, Serialize}; -use crate::docker::exec::exec_oneshot; +use crate::host::ContainerTarget; /// Marks the JSON payload in the probe's stdout, so unrelated chatter on the /// same stream (npm notices, Node warnings) can't be mistaken for the result. @@ -278,11 +278,18 @@ impl PlaywrightDetection { /// `npx @playwright/mcp`, is found as readily as one in /// `/workspace/node_modules`. pub async fn detect(container_id: &str) -> Result { - let output = exec_oneshot( - container_id, - vec!["node".to_string(), "-e".to_string(), PROBE.to_string()], - ) - .await?; + detect_in(&ContainerTarget::docker(container_id)).await +} + +/// [`detect`] against a target already in hand. +pub async fn detect_in(container: &ContainerTarget) -> Result { + let output = container + .exec_oneshot(vec![ + "node".to_string(), + "-e".to_string(), + PROBE.to_string(), + ]) + .await?; parse_probe_output(&output) } diff --git a/app/src-tauri/src/browser_view/install.rs b/app/src-tauri/src/browser_view/install.rs index 87aff0e..a6c149d 100644 --- a/app/src-tauri/src/browser_view/install.rs +++ b/app/src-tauri/src/browser_view/install.rs @@ -67,9 +67,7 @@ use serde::Serialize; use tauri::AppHandle; use crate::commands::project_commands::emit_progress; -use crate::docker::exec::{ - create_attached_exec_as, exec_oneshot_as, wait_for_exec_exit, AttachedExec, -}; +use crate::host::{AttachedExec, ContainerTarget, ExecSpec}; use super::detect::{self, PlaywrightDetection}; @@ -242,13 +240,14 @@ pub async fn install_packages( project_id: &str, container_id: &str, ) -> Result { + let container = ContainerTarget::docker(container_id); emit_progress( app, project_id, &format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR), ); - let mut step = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE]).await?; + let mut step = npm_install(app, project_id, &container, &[VIEWER_PACKAGE]).await?; if step.exit_code != 0 { return Err(format!( "npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}", @@ -265,9 +264,9 @@ pub async fn install_packages( // see the note on `npm_install`. The pin can only be read after the first // install has written the manifest, which is why this stays two commands // rather than one. - let spec = pinned_playwright_spec(container_id).await; + let spec = pinned_playwright_spec(&container).await; emit_progress(app, project_id, &format!("Installing {}…", spec)); - let second = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE, &spec]).await?; + let second = npm_install(app, project_id, &container, &[VIEWER_PACKAGE, &spec]).await?; if second.exit_code != 0 { return Err(format!( "npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}", @@ -279,7 +278,7 @@ pub async fn install_packages( step.log = merge_logs(step.log, second.log); emit_progress(app, project_id, "Re-checking what the container has…"); - let detection = detect::detect(container_id).await?; + let detection = detect::detect_in(&container).await?; // A probe that still finds something missing after a successful npm run is // the interesting case, so it is surfaced rather than swallowed. And a @@ -331,7 +330,7 @@ pub async fn install_packages( async fn npm_install( app: &AppHandle, project_id: &str, - container_id: &str, + container: &ContainerTarget, specs: &[&str], ) -> Result { let mut cmd = vec![ @@ -348,7 +347,7 @@ async fn npm_install( run_step( app, project_id, - container_id, + container, "claude", INSTALL_DIR, cmd, @@ -363,20 +362,20 @@ async fn npm_install( /// Read from the manifest npm just wrote rather than guessed, and falling back /// to `@latest` when it can't be read — an unreadable manifest is a reason to /// install something, not nothing. -async fn pinned_playwright_spec(container_id: &str) -> String { +async fn pinned_playwright_spec(container: &ContainerTarget) -> String { let script = format!( "try{{const d=require('{}/node_modules/@playwright/cli/package.json').dependencies||{{}};\ process.stdout.write(d.playwright||'');}}catch(e){{}}", INSTALL_DIR ); - let (out, _code) = exec_oneshot_as( - container_id, - "claude", - vec!["node".to_string(), "-e".to_string(), script], - Vec::new(), - ) - .await - .unwrap_or_default(); + let (out, _code) = container + .exec_oneshot_as( + "claude", + vec!["node".to_string(), "-e".to_string(), script], + Vec::new(), + ) + .await + .unwrap_or_default(); let version: &str = out.trim(); // A version, not a range or a URL: anything else goes to the fallback @@ -414,7 +413,8 @@ pub async fn install_browser( container_id: &str, target: BrowserTarget, ) -> Result { - let before = detect::detect(container_id).await?; + let container = ContainerTarget::docker(container_id); + let before = detect::detect_in(&container).await?; let Some(cli) = before.playwright_cli.clone() else { return Err( "Playwright isn't installed in this container yet, so there is nothing to install a \ @@ -458,7 +458,7 @@ pub async fn install_browser( project_id, "Step 1/3 — checking whether this container already has the browser system libraries…", ); - let state = check_libraries(container_id, &cli, target).await; + let state = check_libraries(&container, &cli, target).await; match &state { LibraryState::Present => { emit_progress( @@ -482,7 +482,7 @@ pub async fn install_browser( let deps = run_step( app, project_id, - container_id, + &container, "root", "/tmp", vec![ @@ -530,7 +530,7 @@ pub async fn install_browser( let dl = run_step( app, project_id, - container_id, + &container, user, workdir, vec![ @@ -559,7 +559,7 @@ pub async fn install_browser( project_id, &format!("Step 3/3 — checking that {} actually launches…", target.cli_name()), ); - let verdict = verify_launch(container_id, &before, target).await; + let verdict = verify_launch(&container, &before, target).await; push_section(&mut log, &verdict.detail); if !verdict.ok { warning = merge( @@ -590,7 +590,7 @@ pub async fn install_browser( } emit_progress(app, project_id, "Re-checking what the container has…"); - let detection = detect::detect(container_id).await?; + let detection = detect::detect_in(&container).await?; Ok(BrowserSetupOutcome { detection, @@ -642,9 +642,12 @@ impl LibraryState { /// dpkg's status file. A *missing* package on such an image is simply not in /// any index, so apt fails, Playwright reports the failure, and this returns /// [`LibraryState::Unknown`] — which installs, which is the right answer. -async fn check_libraries(container_id: &str, cli: &str, target: BrowserTarget) -> LibraryState { - let run = exec_oneshot_as( - container_id, +async fn check_libraries( + container: &ContainerTarget, + cli: &str, + target: BrowserTarget, +) -> LibraryState { + let run = container.exec_oneshot_as( "root", vec![ "node".to_string(), @@ -708,7 +711,7 @@ struct LaunchVerdict { /// Launch the browser headless, load one page, and close it. Seconds, and it is /// the only thing that distinguishes "downloaded" from "usable". async fn verify_launch( - container_id: &str, + container: &ContainerTarget, detection: &PlaywrightDetection, target: BrowserTarget, ) -> LaunchVerdict { @@ -724,8 +727,7 @@ async fn verify_launch( }; let dir = manifest.trim_end_matches("package.json").trim_end_matches('/'); - let run = exec_oneshot_as( - container_id, + let run = container.exec_oneshot_as( "claude", vec![ "node".to_string(), @@ -884,13 +886,13 @@ impl StepResult { /// Run one command in the container, streaming every line it prints to the pane /// as `container-progress` and keeping the tail for the result. /// -/// Uses `create_attached_exec_as` — the single attached-exec path — rather than +/// Uses [`ContainerTarget::attach`] — the single attached-exec path — rather than /// `exec_oneshot`, because these commands run for minutes and the point is that /// the user can watch them. async fn run_step( app: &AppHandle, project_id: &str, - container_id: &str, + container: &ContainerTarget, user: &str, workdir: &str, cmd: Vec, @@ -900,7 +902,14 @@ async fn run_step( exec_id, mut output, input, - } = create_attached_exec_as(container_id, cmd, false, user, workdir).await?; + } = container + .attach( + ExecSpec::new(cmd) + .tty(false) + .user(user) + .working_dir(Some(workdir)), + ) + .await?; // Nothing is ever written to this exec. Closing stdin means anything that // would prompt sees EOF and gives up, instead of waiting for a person who @@ -944,7 +953,7 @@ async fn run_step( )), Ok(Err(e)) => Err(e), Ok(Ok(())) => Ok(StepResult { - exit_code: wait_for_exec_exit(&exec_id).await.unwrap_or(0), + exit_code: container.wait_exec_exit(&exec_id).await.unwrap_or(0), log, }), } diff --git a/app/src-tauri/src/browser_view/mod.rs b/app/src-tauri/src/browser_view/mod.rs index dd35dcb..374acb3 100644 --- a/app/src-tauri/src/browser_view/mod.rs +++ b/app/src-tauri/src/browser_view/mod.rs @@ -50,7 +50,7 @@ //! | Trigger | Path | //! |---|---| //! | Turned off in the UI | `set_browser_view_enabled(false)` → persist `false`, then [`BrowserViewManager::stop`] | -//! | Container stopped, by the UI or otherwise | supervisor's `is_container_running` check | +//! | Container stopped, by the UI or otherwise | supervisor's `is_running` check | //! | Project deleted | supervisor's `store.get()` check | //! | Container rebuilt | old container stops → supervisor exits; the new one is not auto-started | //! | Viewer died in the container | supervisor's periodic HTTP liveness probe | @@ -90,8 +90,7 @@ use tokio::sync::{watch, Mutex}; use tokio::task::JoinHandle; use crate::auth_bridge::proc_net::{self, PortFamily}; -use crate::docker::container::is_container_running; -use crate::docker::exec::exec_oneshot; +use crate::host::ContainerTarget; use crate::storage::projects_store::ProjectsStore; use detect::PlaywrightDetection; @@ -300,7 +299,8 @@ impl BrowserViewManager { return Ok(self.status(&project_id, true).await); } - let detection = detect::detect(&container_id).await?; + let container = ContainerTarget::docker(container_id.as_str()); + let detection = detect::detect_in(&container).await?; if !detection.is_usable() { let blocker = detection.blocker().unwrap_or_else(|| { "Playwright is present but incomplete in this container.".to_string() @@ -320,9 +320,9 @@ impl BrowserViewManager { // Doing this before choosing a port also frees the one a previous // session was using, so sessions don't walk up the range. Best-effort: // a container with no dashboard makes this a no-op. - let _ = kill_dashboard(&container_id, &cli_entry).await; + let _ = kill_dashboard(&container, &cli_entry).await; - let (container_port, entry_path) = start_viewer(&container_id, &cli_entry).await?; + let (container_port, entry_path) = start_viewer(&container, &cli_entry).await?; let token = generate_token(); // `--host 127.0.0.1` is ours to set, so the family is known and there is @@ -337,7 +337,7 @@ impl BrowserViewManager { { Ok(p) => p, Err(e) => { - let _ = kill_dashboard(&container_id, &cli_entry).await; + let _ = kill_dashboard(&container, &cli_entry).await; return Err(e); } }; @@ -354,7 +354,7 @@ impl BrowserViewManager { let (cancel_tx, cancel_rx) = watch::channel(false); let supervisor = tokio::spawn(supervise( project_id.clone(), - container_id.clone(), + container, cli_entry, container_port, epoch, @@ -427,7 +427,7 @@ impl BrowserViewManager { #[allow(clippy::too_many_arguments)] async fn supervise( project_id: String, - container_id: String, + container: ContainerTarget, cli_entry: String, container_port: u16, epoch: u64, @@ -443,7 +443,7 @@ async fn supervise( log::info!("Browser view: project {} is gone — tearing down", project_id); break; } - if !is_container_running(&container_id).await.unwrap_or(false) { + if !container.is_running().await.unwrap_or(false) { log::info!( "Browser view: container for project {} is no longer running — tearing down", project_id @@ -459,7 +459,7 @@ async fn supervise( // view never waits out an in-flight exec. let alive = tokio::select! { _ = cancel.changed() => break, - res = probe_entry_path(&container_id, container_port) => res.is_ok(), + res = probe_entry_path(&container, container_port) => res.is_ok(), }; if !alive { log::warn!( @@ -477,7 +477,7 @@ async fn supervise( } proxy.shutdown().await; - let _ = kill_dashboard(&container_id, &cli_entry).await; + let _ = kill_dashboard(&container, &cli_entry).await; // Deregister, unless a newer session has already taken this project's slot. let superseded = { @@ -538,7 +538,11 @@ const VIEWER_LOG: &str = "/tmp/triple-c-browser-view.log"; /// redirected to [`VIEWER_LOG`] (both so `exec_oneshot` can return immediately /// rather than waiting on an inherited stdout, and so a failure has a trail), /// and readiness is established by [`wait_until_ready`] instead. -async fn launch_viewer(container_id: &str, cli_entry: &str, port: u16) -> Result<(), String> { +async fn launch_viewer( + container: &ContainerTarget, + cli_entry: &str, + port: u16, +) -> Result<(), String> { // `NO_UPDATE_NOTIFIER` stops the CLI phoning registry.npmjs.org on every // launch; the container may have no egress, and we don't want to wait out a // DNS timeout before the dashboard binds. @@ -549,13 +553,11 @@ async fn launch_viewer(container_id: &str, cli_entry: &str, port: u16) -> Result port, VIEWER_LOG ); - exec_oneshot( - container_id, - vec!["sh".to_string(), "-c".to_string(), script], - ) - .await - .map(|_| ()) - .map_err(|e| format!("Could not start the Playwright viewer: {}", e)) + container + .exec_oneshot(vec!["sh".to_string(), "-c".to_string(), script]) + .await + .map(|_| ()) + .map_err(|e| format!("Could not start the Playwright viewer: {}", e)) } /// The dashboard singleton is keyed on a hash of the working directory, so @@ -567,17 +569,15 @@ async fn launch_viewer(container_id: &str, cli_entry: &str, port: u16) -> Result const WORKDIR_PREFIX: &str = "cd /workspace 2>/dev/null || true"; /// Stop the dashboard daemon. Verified to free the port and stop answering. -async fn kill_dashboard(container_id: &str, cli_entry: &str) -> Result { +async fn kill_dashboard(container: &ContainerTarget, cli_entry: &str) -> Result { let script = format!( "{}; NO_UPDATE_NOTIFIER=1 node {} show --kill", WORKDIR_PREFIX, shell_quote(cli_entry) ); - exec_oneshot( - container_id, - vec!["sh".to_string(), "-c".to_string(), script], - ) - .await + container + .exec_oneshot(vec!["sh".to_string(), "-c".to_string(), script]) + .await } /// Turn a failed start into something the user can act on. @@ -604,13 +604,16 @@ fn explain_start_failure(err: &str, log: &str) -> String { } /// Tail of the viewer's own output, for a start that didn't come up. -async fn read_viewer_log(container_id: &str) -> String { - exec_oneshot( - container_id, - vec!["tail".to_string(), "-n".to_string(), "40".to_string(), VIEWER_LOG.to_string()], - ) - .await - .unwrap_or_default() +async fn read_viewer_log(container: &ContainerTarget) -> String { + container + .exec_oneshot(vec![ + "tail".to_string(), + "-n".to_string(), + "40".to_string(), + VIEWER_LOG.to_string(), + ]) + .await + .unwrap_or_default() } // ───────────────────────────────────────────────────────────────────────────── @@ -645,14 +648,17 @@ const MAX_PORT_ATTEMPTS: usize = 3; /// this layer, and the pane would then front it. What contains that is /// downstream — the host proxy is loopback-only and token-gated, and the pane's /// iframe is sandboxed — not this function. -async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, String), String> { +async fn start_viewer( + container: &ContainerTarget, + cli_entry: &str, +) -> Result<(u16, String), String> { let mut tried: Vec = Vec::new(); let mut last: Option = None; for _ in 0..MAX_PORT_ATTEMPTS { // Re-read the listener snapshot each attempt: the port that was free a // moment ago is exactly the one we may have just lost. - let port = match pick_viewer_port(container_id, &tried).await { + let port = match pick_viewer_port(container, &tried).await { Ok(p) => p, Err(e) => { // Report why the *attempts* failed, not just "nothing free": @@ -666,21 +672,21 @@ async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, Strin }; tried.push(port); - launch_viewer(container_id, cli_entry, port).await?; + launch_viewer(container, cli_entry, port).await?; // Wait for it to actually answer, and learn the entry URL while we're // there — see `probe_entry_path` for why that matters. This, not the // launcher's stdout, is the readiness signal: verified that the // "Listening on …" line is printed only on the very first start. - match wait_until_ready(container_id, port).await { + match wait_until_ready(container, port).await { Ok(path) => return Ok((port, path)), Err(e) => { - let log = read_viewer_log(container_id).await; + let log = read_viewer_log(container).await; // Always kill before retrying: the dashboard is a singleton, so // a launcher that came up on some *other* port would otherwise // make every further attempt a no-op that silently ignores the // port we asked for. - let _ = kill_dashboard(container_id, cli_entry).await; + let _ = kill_dashboard(container, cli_entry).await; last = Some(explain_start_failure(&e, &log)); } } @@ -691,10 +697,9 @@ async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, Strin /// First port in [`VIEWER_PORTS`] that nothing in the container is listening on /// and that this start has not already tried. -async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result { - let text = exec_oneshot( - container_id, - vec![ +async fn pick_viewer_port(container: &ContainerTarget, tried: &[u16]) -> Result { + let text = container + .exec_oneshot(vec![ // Absolute path, deliberately, for the same reason the auth bridge // uses one: `container/Dockerfile` puts a container-writable // directory first on `PATH`, so a bare `cat` is a name the container @@ -704,10 +709,9 @@ async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result Result Result { +async fn wait_until_ready(container: &ContainerTarget, port: u16) -> Result { let deadline = tokio::time::Instant::now() + READY_TIMEOUT; loop { - let last = match probe_entry_path(container_id, port).await { + let last = match probe_entry_path(container, port).await { Ok(path) => return Ok(path), Err(e) => e, }; @@ -751,7 +755,7 @@ const PROBE_MARKER: &str = "__TRIPLE_C_BV_PATH__"; /// a redirect — which matters, because a redirect drops the `?token=` the proxy /// gate wants and would leave a fresh connection to be authorised with nothing. /// A `200` (no redirect) is fine too; then the entry point is just `/`. -async fn probe_entry_path(container_id: &str, port: u16) -> Result { +async fn probe_entry_path(container: &ContainerTarget, port: u16) -> Result { // The request is bounded on both sides. Verified: the dashboard answers a // bad WebSocket path by holding the socket open forever rather than // erroring, so "no reply" is a state this probe has to be able to leave — @@ -766,10 +770,7 @@ async fn probe_entry_path(container_id: &str, port: u16) -> Result PageState { "try{{process.stdout.write(require('fs').readFileSync('{}','utf8'));}}catch(e){{}}", STATE_PATH ); - let Ok((out, _)) = exec_oneshot_as( - container_id, - "claude", - vec!["node".to_string(), "-e".to_string(), script], - Vec::new(), - ) - .await + let Ok((out, _)) = ContainerTarget::docker(container_id) + .exec_oneshot_as( + "claude", + vec!["node".to_string(), "-e".to_string(), script], + Vec::new(), + ) + .await else { return PageState::default(); }; @@ -209,15 +209,15 @@ async fn write_control(container_id: &str, json: String) -> Result<(), String> { "require('fs').writeFileSync('{}',process.argv[1]);", CONTROL_PATH ); - exec_oneshot_as( - container_id, - "claude", - vec!["node".to_string(), "-e".to_string(), script, json], - Vec::new(), - ) - .await - .map(|_| ()) - .map_err(|e| format!("Could not reach the browser helper: {}", e)) + ContainerTarget::docker(container_id) + .exec_oneshot_as( + "claude", + vec!["node".to_string(), "-e".to_string(), script, json], + Vec::new(), + ) + .await + .map(|_| ()) + .map_err(|e| format!("Could not reach the browser helper: {}", e)) } /// Wait for a *running* helper to report the URL we just asked it for. diff --git a/app/src-tauri/src/commands/auth_token_commands.rs b/app/src-tauri/src/commands/auth_token_commands.rs index c56545a..69b01d1 100644 --- a/app/src-tauri/src/commands/auth_token_commands.rs +++ b/app/src-tauri/src/commands/auth_token_commands.rs @@ -76,8 +76,7 @@ use tauri::{AppHandle, Emitter, State}; use tokio::io::AsyncWriteExt; use tokio::sync::{mpsc, oneshot, Mutex}; -use crate::docker::container::is_container_running; -use crate::docker::exec::{create_attached_exec, wait_for_exec_exit, AttachedExec}; +use crate::host::{AttachedExec, ContainerTarget}; use crate::storage::secure; use crate::AppState; @@ -867,7 +866,7 @@ async fn type_code_then_enter( async fn run_setup_token( app: &AppHandle, project_id: &str, - container_id: &str, + target: &ContainerTarget, mut input_rx: mpsc::UnboundedReceiver>, mut cancel_rx: oneshot::Receiver<()>, ) -> Result { @@ -877,16 +876,16 @@ async fn run_setup_token( exec_id, mut output, mut input, - } = create_attached_exec( - container_id, - vec![ - "sh".to_string(), - "-c".to_string(), - SETUP_TOKEN_SCRIPT.to_string(), - ], - true, - ) - .await?; + } = target + .attach_exec( + vec![ + "sh".to_string(), + "-c".to_string(), + SETUP_TOKEN_SCRIPT.to_string(), + ], + true, + ) + .await?; let mut stripper = AnsiStripper::default(); let mut redactor = SecretRedactor::default(); @@ -1041,7 +1040,7 @@ async fn run_setup_token( // Falling through to the token parse is right either way — a run that // printed a token succeeded whatever Docker says about it — but it is worth // saying so rather than silently calling it a clean exit. - match wait_for_exec_exit(&exec_id).await { + match target.wait_exec_exit(&exec_id).await { Some(0) => {} Some(code) => { return Err(format!( @@ -1091,13 +1090,13 @@ pub async fn acquire_claude_token( .get(&project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; - let container_id = project.container_id.clone().ok_or_else(|| { + let target = ContainerTarget::for_project(&project).ok_or_else(|| { format!( "Project '{}' has no container yet. Start it, then run authentication again.", project.name ) })?; - if !is_container_running(&container_id).await.unwrap_or(false) { + if !target.is_running().await.unwrap_or(false) { return Err(format!( "The container for '{}' is not running. Start it, then run authentication again.", project.name @@ -1133,8 +1132,7 @@ pub async fn acquire_claude_token( "Running `claude setup-token` — sign in at the URL below, then submit the code it gives you.", ); - let result = - run_setup_token(&app_handle, &project_id, &container_id, input_rx, cancel_rx).await; + let result = run_setup_token(&app_handle, &project_id, &target, input_rx, cancel_rx).await; // Release the flow. Nothing else needs unwinding: this command changes no // persisted state until the token itself is stored, which is the point. diff --git a/app/src-tauri/src/commands/marketplace_commands.rs b/app/src-tauri/src/commands/marketplace_commands.rs index 0f96436..c346224 100644 --- a/app/src-tauri/src/commands/marketplace_commands.rs +++ b/app/src-tauri/src/commands/marketplace_commands.rs @@ -7,7 +7,7 @@ use std::collections::{BTreeMap, HashSet}; use tauri::{AppHandle, Emitter, State}; use tokio::sync::oneshot; -use crate::docker::container::is_container_running; +use crate::host::ContainerTarget; use crate::marketplace::{self as mk, auth, diff, gh_login, git, MarketplaceManager}; use crate::models::marketplace::{ is_valid_commit, is_valid_item_key, AccountMethod, FileDiff, InstallScope, ItemUpdate, @@ -944,8 +944,8 @@ pub async fn apply_marketplace_now( }; let mut results = Vec::new(); for p in projects { - let running = match &p.container_id { - Some(cid) => is_container_running(cid).await.unwrap_or(false), + let running = match ContainerTarget::for_project(&p) { + Some(target) => target.is_running().await.unwrap_or(false), None => false, }; if !running { @@ -1051,13 +1051,13 @@ pub async fn start_marketplace_gh_container_login( return Err("Signing in through a container needs a host name without a port.".to_string()); } let project = find_project(&state, &project_id)?; - let container_id = project.container_id.clone().ok_or_else(|| { + let target = ContainerTarget::for_project(&project).ok_or_else(|| { format!( "\"{}\" has no container yet. Start it, then try again.", project.name ) })?; - if !is_container_running(&container_id).await.unwrap_or(false) { + if !target.is_running().await.unwrap_or(false) { return Err(format!( "\"{}\" is not running. Start it, then try again.", project.name @@ -1070,7 +1070,7 @@ pub async fn start_marketplace_gh_container_login( } let account_id = uuid::Uuid::new_v4().to_string(); let result = - gh_login::run_gh_container_login(&app_handle, &account_id, &container_id, &host, rx).await; + gh_login::run_gh_container_login(&app_handle, &account_id, &target, &host, rx).await; // `rx` is gone now, so this frees our slot and never a newer login's. state.marketplace.release_gh_login().await; let token = result?; diff --git a/app/src-tauri/src/commands/terminal_commands.rs b/app/src-tauri/src/commands/terminal_commands.rs index 5b95164..3cab350 100644 --- a/app/src-tauri/src/commands/terminal_commands.rs +++ b/app/src-tauri/src/commands/terminal_commands.rs @@ -1,6 +1,7 @@ use tauri::{AppHandle, Emitter, State}; use crate::commands::aws_commands; +use crate::host::ContainerTarget; use crate::models::{Backend, BedrockAuthMethod, Project}; use crate::AppState; @@ -159,9 +160,7 @@ pub async fn open_terminal_session( .get(&project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; - let container_id = project - .container_id - .as_ref() + let target = ContainerTarget::for_project(&project) .ok_or_else(|| "Container not running".to_string())?; if let Some(id) = resume_session_id.as_deref() { @@ -189,7 +188,7 @@ pub async fn open_terminal_session( state .exec_manager .create_session( - &crate::host::ContainerTarget::docker(container_id.as_str()), + &target, &session_id, cmd, move |data| { @@ -240,7 +239,7 @@ pub async fn paste_image_to_terminal( image_data: Vec, state: State<'_, AppState>, ) -> Result { - let container_id = state.exec_manager.get_container_id(&session_id).await?; + let target = ContainerTarget::docker(state.exec_manager.get_container_id(&session_id).await?); let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -250,11 +249,7 @@ pub async fn paste_image_to_terminal( state .exec_manager - .write_file_to_container( - &crate::host::ContainerTarget::docker(container_id.as_str()), - &file_name, - &image_data, - ) + .write_file_to_container(&target, &file_name, &image_data) .await } @@ -287,7 +282,7 @@ pub async fn upload_host_file_to_terminal( let base = crate::commands::file_commands::host_upload_name(&host_path)?; let host_path = crate::commands::file_commands::resolve_host_read_path(&host_path).await?; - let container_id = state.exec_manager.get_container_id(&session_id).await?; + let target = ContainerTarget::docker(state.exec_manager.get_container_id(&session_id).await?); let meta = tokio::fs::metadata(&host_path) .await @@ -317,7 +312,7 @@ pub async fn upload_host_file_to_terminal( // with the code that does the reading, which re-applies it to the open // descriptor — this check is here only so the refusal reads like a sentence // instead of arriving after a 300 MB read. - use crate::docker::exec::MAX_DROP_BYTES; + use crate::host::MAX_DROP_BYTES; if meta.len() > MAX_DROP_BYTES { return Err(format!( "File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project instead.", @@ -330,20 +325,18 @@ pub async fn upload_host_file_to_terminal( // Ensure the destination directory exists rather than relying on Docker's // archive extractor to create the parent for the uploaded tar entry. - crate::docker::exec::exec_oneshot( - &container_id, - vec!["mkdir".to_string(), "-p".to_string(), "/tmp/triple-c-drops".to_string()], - ) - .await?; + target + .exec_oneshot(vec![ + "mkdir".to_string(), + "-p".to_string(), + "/tmp/triple-c-drops".to_string(), + ]) + .await?; let file_name = format!("triple-c-drops/{}", base); - crate::docker::exec::upload_host_file_to_container( - &container_id, - &host_path, - "/tmp", - &file_name, - ) - .await + target + .upload_host_file(&host_path, "/tmp", &file_name) + .await } #[tauri::command] @@ -351,8 +344,8 @@ pub async fn start_audio_bridge( session_id: String, state: State<'_, AppState>, ) -> Result<(), String> { - // Get container_id from the terminal session - let container_id = state.exec_manager.get_container_id(&session_id).await?; + // Get the container from the terminal session + let target = ContainerTarget::docker(state.exec_manager.get_container_id(&session_id).await?); // Create audio bridge exec session with ID "audio-{session_id}" // The loop handles reconnection when the FIFO reader (fake rec) is killed and restarted @@ -366,7 +359,7 @@ pub async fn start_audio_bridge( state .exec_manager .create_session_with_tty( - &crate::host::ContainerTarget::docker(container_id.as_str()), + &target, &audio_session_id, cmd, false, diff --git a/app/src-tauri/src/file_viewer/poll.rs b/app/src-tauri/src/file_viewer/poll.rs index d7301cc..28dd359 100644 --- a/app/src-tauri/src/file_viewer/poll.rs +++ b/app/src-tauri/src/file_viewer/poll.rs @@ -7,7 +7,7 @@ use serde::Serialize; -use crate::docker::exec::exec_oneshot_streams_as; +use crate::host::ContainerTarget; #[derive(Clone, Debug, Serialize, PartialEq, Eq)] pub struct ViewerPoll { @@ -48,8 +48,9 @@ pub async fn poll_file(container_id: &str, container_path: &str) -> Result Result, String> { let mut cmd: Vec = vec!["sh".into(), "-c".into(), PROBE_SCRIPT.into(), "probe".into()]; cmd.extend(candidates.iter().cloned()); - let (stdout, _stderr, _code) = - exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?; + let (stdout, _stderr, _code) = ContainerTarget::docker(container_id) + .exec_oneshot_streams_as("claude", cmd, Vec::new()) + .await?; Ok(parse_probe_output(&stdout)) } diff --git a/app/src-tauri/src/marketplace/gh_login.rs b/app/src-tauri/src/marketplace/gh_login.rs index 3382c29..760e85a 100644 --- a/app/src-tauri/src/marketplace/gh_login.rs +++ b/app/src-tauri/src/marketplace/gh_login.rs @@ -5,16 +5,13 @@ use std::time::Duration; -use crate::host::ExecChunk; use futures_util::{Stream, StreamExt}; use tauri::{AppHandle, Emitter}; use tokio::io::{AsyncWrite, AsyncWriteExt}; use tokio::sync::oneshot; use crate::commands::auth_token_commands::{push_capped_tail, AnsiStripper, SUBMIT_ENTER_DELAY}; -use crate::docker::exec::{ - create_attached_exec_as, exec_oneshot_as, wait_for_exec_exit, AttachedExec, -}; +use crate::host::{AttachedExec, ContainerTarget, ExecChunk, ExecSpec}; pub const CODE_EVENT: &str = "marketplace-gh-login-code"; pub const OUTPUT_EVENT: &str = "marketplace-gh-login-output"; @@ -32,8 +29,8 @@ const MAX_PENDING_LINE: usize = 4096; /// script around it (whose text contains the same words); errors are ignored. const CANCEL_PKILL: [&str; 3] = ["pkill", "-f", "gh auth login --hostname"]; -/// Constant script; the host is `$1` (argv, never interpolated), because -/// `create_attached_exec_as` takes no env. +/// Constant script; the host is `$1` (argv, never interpolated); the attached +/// exec is opened with no extra env. /// /// * `GH_CONFIG_DIR` / `GIT_CONFIG_GLOBAL` live in a temp dir removed on exit, /// so the container is never left logged in. The `HUP INT TERM` trap turns a @@ -150,9 +147,9 @@ fn failure_tail(transcript: &str) -> String { /// Pre-flight N9 / review fix 1: stop the in-container login after any /// failed attempt. -async fn kill_container_login(container_id: &str) { +async fn kill_container_login(target: &ContainerTarget) { let cmd = CANCEL_PKILL.iter().map(|s| s.to_string()).collect(); - let _ = exec_oneshot_as(container_id, "claude", cmd, vec![]).await; + let _ = target.exec_oneshot_as("claude", cmd, vec![]).await; } /// Hand `result` back, running `cleanup` first when it is a failure. @@ -262,7 +259,7 @@ where pub async fn run_gh_container_login( app: &AppHandle, account_id: &str, - container_id: &str, + target: &ContainerTarget, host: &str, mut cancel: oneshot::Receiver<()>, ) -> Result { @@ -273,20 +270,20 @@ pub async fn run_gh_container_login( exec_id, output, input, - } = create_attached_exec_as( - container_id, - vec![ - "sh".to_string(), - "-c".to_string(), - GH_LOGIN_SCRIPT.to_string(), - "triple-c-gh-login".to_string(), - host.to_string(), - ], - true, - "claude", - "/home/claude", - ) - .await?; + } = target + .attach( + ExecSpec::new(vec![ + "sh".to_string(), + "-c".to_string(), + GH_LOGIN_SCRIPT.to_string(), + "triple-c-gh-login".to_string(), + host.to_string(), + ]) + .tty(true) + .user("claude") + .working_dir(Some("/home/claude")), + ) + .await?; let deadline = tokio::time::Instant::now() + LOGIN_TIMEOUT; let result = async { @@ -305,7 +302,7 @@ pub async fn run_gh_container_login( if let Some(token) = extract_token(&transcript) { return Ok(token); } - let status = wait_for_exec_exit(&exec_id).await; + let status = target.wait_exec_exit(&exec_id).await; Err(format!( "gh did not complete the sign-in (exit status {}). Nothing was stored.\n{}", status @@ -315,7 +312,7 @@ pub async fn run_gh_container_login( )) } .await; - cleanup_on_error(result, || kill_container_login(container_id)).await + cleanup_on_error(result, || kill_container_login(target)).await } #[cfg(test)] diff --git a/app/src-tauri/src/marketplace/sync.rs b/app/src-tauri/src/marketplace/sync.rs index 7ce9607..7c7352b 100644 --- a/app/src-tauri/src/marketplace/sync.rs +++ b/app/src-tauri/src/marketplace/sync.rs @@ -4,7 +4,7 @@ use std::time::Duration; use super::payload::Payload; -use crate::docker::exec::{exec_oneshot_as, exec_oneshot_streams_as, upload_bytes_to_container}; +use crate::host::ContainerTarget; use crate::models::marketplace::{SkippedItem, SyncReport}; /// Where the payload and the script are uploaded. Owned by `claude`. @@ -57,10 +57,12 @@ fn run_script_env() -> Vec { vec!["HOME=/home/claude".to_string()] } -async fn wait_until_ready(container_id: &str) -> Result<(), String> { +async fn wait_until_ready(target: &ContainerTarget) -> Result<(), String> { let deadline = tokio::time::Instant::now() + READY_TIMEOUT; loop { - let (_, code) = exec_oneshot_as(container_id, "root", ready_probe_cmd(), vec![]).await?; + let (_, code) = target + .exec_oneshot_as("root", ready_probe_cmd(), vec![]) + .await?; if code == 0 { return Ok(()); } @@ -91,34 +93,28 @@ fn tail(text: &str, max: usize) -> &str { /// Wait for readiness, upload the payload and the script, run the script as /// `claude`, and return its report. pub async fn sync_container(container_id: &str, payload: &Payload) -> Result { - wait_until_ready(container_id).await?; + let target = ContainerTarget::docker(container_id); + wait_until_ready(&target).await?; - let (out, code) = exec_oneshot_as(container_id, "root", sh(PREPARE_SCRIPT), vec![]).await?; + let (out, code) = target + .exec_oneshot_as("root", sh(PREPARE_SCRIPT), vec![]) + .await?; if code != 0 { return Err(format!( "Could not prepare the container for the marketplace sync: {}", tail(&out, 500) )); } - upload_bytes_to_container( - container_id, - INCOMING_DIR, - "payload.tar", - &payload.tar, - 0o644, - ) - .await?; - upload_bytes_to_container( - container_id, - INCOMING_DIR, - "sync.sh", - SYNC_SCRIPT.as_bytes(), - 0o755, - ) - .await?; + target + .upload_bytes(INCOMING_DIR, "payload.tar", &payload.tar, 0o644) + .await?; + target + .upload_bytes(INCOMING_DIR, "sync.sh", SYNC_SCRIPT.as_bytes(), 0o755) + .await?; - let (stdout, stderr, code) = - exec_oneshot_streams_as(container_id, "claude", run_script_cmd(), run_script_env()).await?; + let (stdout, stderr, code) = target + .exec_oneshot_streams_as("claude", run_script_cmd(), run_script_env()) + .await?; parse_report(&stdout).map_err(|e| { format!( "The marketplace sync script failed (exit {code}): {e}. {}",