Merge pull request 'Host abstraction, phase 1a: ContainerTarget for container-interior operations' (#74) from refactor/project-host into main
Secret Scan / scan (push) Successful in 5s
Build App / compute-version (push) Successful in 5s
Build App / build-macos (push) Successful in 3m16s
Build App / build-linux (push) Successful in 6m24s
Build App / build-windows (push) Successful in 7m3s
Build App / create-tag (push) Successful in 7s
Build App / sync-to-github (push) Successful in 21s

This commit was merged in pull request #74.
This commit is contained in:
jknapp committed 2026-11-13 19:12:23 +00:00
commit 3ec100e06c
26 files changed
+1280 -774

No files matched your search

+11 -2
View File
@@ -264,8 +264,8 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- **`docker/`** — Docker API layer using bollard: - **`docker/`** — Docker API layer using bollard:
- `client.rs` — Singleton Docker connection via `OnceLock` - `client.rs` — Singleton Docker connection via `OnceLock`
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect) - `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
- `exec.rs` — Attached exec streaming. `create_attached_exec()` is the **single** place an - `exec.rs` — Docker's implementation of everything `host::ContainerTarget` does inside a
attached exec is opened; terminal sessions and the auth bridge both go through it. container. `create_attached_exec_spec()` is the **single** place an attached exec is opened.
- `image.rs` — Image build/pull with progress streaming - `image.rs` — Image build/pull with progress streaming
- `gateway.rs` — Optional LiteLLM sibling container giving Claude Code an Anthropic-format - `gateway.rs` — Optional LiteLLM sibling container giving Claude Code an Anthropic-format
front end for providers that only speak OpenAI (see `gateway-container/`). Mirrors `stt.rs`. front end for providers that only speak OpenAI (see `gateway-container/`). Mirrors `stt.rs`.
@@ -282,6 +282,15 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- `legacy_cleanup.rs` — One-release migration shim removing leftovers from the deleted MCP - `legacy_cleanup.rs` — One-release migration shim removing leftovers from the deleted MCP
feature (containers labelled `triple-c.mcp-server`, `triple-c-net-*` networks). Deletable once feature (containers labelled `triple-c.mcp-server`, `triple-c-net-*` networks). Deletable once
users have migrated. users have migrated.
- **`host/`** — Where a project's container runs, and the only way into it. `ContainerTarget`
(one `Docker` variant today; AWS planned — `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md`)
wraps execs, file transfer and `is_running`; `sessions.rs` holds `ExecSessionManager`.
**Container-interior code must go through `ContainerTarget`, never `docker::exec`, bollard or
`get_docker()`** — `host::tests::no_container_interior_bollard_outside_the_host_layer` fails
the build on it. Its allowlist is Docker-only code (migration) plus lifecycle/`die` events,
which phase 1b moves; shrink it, never grow it. Prefer `ContainerTarget::for_project` or a
target a session already holds (`ExecSessionManager::get_target`) over
`ContainerTarget::docker(id)`, which hard-codes the host.
- **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server: - **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server:
- `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades - `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades
- `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect - `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect
+1
View File
@@ -6618,6 +6618,7 @@ dependencies = [
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
"bollard", "bollard",
"bytes",
"chrono", "chrono",
"dirs", "dirs",
"fern", "fern",
+1
View File
@@ -21,6 +21,7 @@ bollard = "0.18"
keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] } keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
futures-util = "0.3" futures-util = "0.3"
bytes = "1"
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
dirs = "6" dirs = "6"
+16 -11
View File
@@ -32,7 +32,7 @@
//! |---|---| //! |---|---|
//! | Bridge disabled | `set_auth_bridge_enabled(false)` → [`AuthBridgeManager::stop`] | //! | Bridge disabled | `set_auth_bridge_enabled(false)` → [`AuthBridgeManager::stop`] |
//! | Container stopped via UI | `stop_project_container` → [`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 | //! | Project deleted | `remove_project` → [`AuthBridgeManager::stop`]; also the poller's `store.get()` check |
//! | Container rebuilt | `rebuild_project_container` → stop, then start re-arms it | //! | Container rebuilt | `rebuild_project_container` → stop, then start re-arms it |
//! | App exit | window `CloseRequested` → [`AuthBridgeManager::stop_all`] | //! | App exit | window `CloseRequested` → [`AuthBridgeManager::stop_all`] |
@@ -55,8 +55,7 @@ use tauri::{AppHandle, Emitter};
use tokio::sync::{watch, Mutex}; use tokio::sync::{watch, Mutex};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use crate::docker::container::is_container_running; use crate::host::{ContainerTarget, PROC_NET_OUTPUT_LIMIT};
use crate::docker::exec::{exec_oneshot_limited, PROC_NET_OUTPUT_LIMIT};
use crate::storage::projects_store::ProjectsStore; use crate::storage::projects_store::ProjectsStore;
use proc_net::PortFamily; use proc_net::PortFamily;
@@ -205,7 +204,7 @@ impl AuthBridgeManager {
let poller = tokio::spawn(poll_loop( let poller = tokio::spawn(poll_loop(
project_id.clone(), project_id.clone(),
container_id, ContainerTarget::docker(container_id),
epoch, epoch,
app, app,
store, store,
@@ -276,7 +275,7 @@ impl AuthBridgeManager {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
async fn poll_loop( async fn poll_loop(
project_id: String, project_id: String,
container_id: String, container: ContainerTarget,
epoch: u64, epoch: u64,
app: AppHandle, app: AppHandle,
store: Arc<ProjectsStore>, store: Arc<ProjectsStore>,
@@ -301,7 +300,7 @@ async fn poll_loop(
log::info!("Auth bridge: disabled for project {} — tearing down", project_id); log::info!("Auth bridge: disabled for project {} — tearing down", project_id);
break; break;
} }
if !is_container_running(&container_id).await.unwrap_or(false) { if !container.is_running().await.unwrap_or(false) {
log::info!( log::info!(
"Auth bridge: container for project {} is no longer running — tearing down", "Auth bridge: container for project {} is no longer running — tearing down",
project_id project_id
@@ -327,7 +326,7 @@ async fn poll_loop(
// bridge or stopping the container doesn't wait out an in-flight poll. // bridge or stopping the container doesn't wait out an in-flight poll.
let discovery = tokio::select! { let discovery = tokio::select! {
_ = cancel.changed() => break, _ = 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) res.map(|(out, _)| out)
} }
}; };
@@ -340,7 +339,7 @@ async fn poll_loop(
// gateway/STT/web-terminal ports can be re-pointed while the // gateway/STT/web-terminal ports can be re-pointed while the
// bridge is running, and a stale reservation set is a hole. // bridge is running, and a stale reservation set is a hole.
let skip = skipped_ports(&project, &store.list(), &app_settings(&app)); 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; 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 /// Bring the set of host listeners in line with what the container is currently
/// listening on. Returns whether anything the UI cares about changed. /// listening on. Returns whether anything the UI cares about changed.
async fn reconcile( async fn reconcile(
container_id: &str, container: &ContainerTarget,
discovered: &BTreeMap<u16, PortFamily>, discovered: &BTreeMap<u16, PortFamily>,
skip: &HashSet<u16>, skip: &HashSet<u16>,
state: &Arc<Mutex<BridgeState>>, state: &Arc<Mutex<BridgeState>>,
@@ -583,7 +582,7 @@ async fn reconcile(
); );
continue; continue;
} }
match PortForward::bind(container_id.to_string(), port, family).await { match PortForward::bind(container.clone(), port, family).await {
Ok(forward) => { Ok(forward) => {
if st.conflicts.remove(&port).is_some() { if st.conflicts.remove(&port).is_some() {
log::info!("Auth bridge: host port {} became available", port); log::info!("Auth bridge: host port {} became available", port);
@@ -833,7 +832,13 @@ mod tests {
(45000u16..45200).map(|p| (p, PortFamily::V4)).collect(); (45000u16..45200).map(|p| (p, PortFamily::V4)).collect();
let state = Arc::new(Mutex::new(BridgeState::default())); 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; let mut st = state.lock().await;
assert!( assert!(
+39 -14
View File
@@ -11,7 +11,7 @@
//! and stdout wired to the TCP socket. `socat` ships in the container image. //! and stdout wired to the TCP socket. `socat` ships in the container image.
//! //!
//! The exec plumbing itself is *not* reimplemented here: it comes from //! 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. //! interactive terminal sessions are built on.
//! //!
//! ## What the host listener is, and is not //! ## What the host listener is, and is not
@@ -56,14 +56,12 @@
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
use std::time::Duration; use std::time::Duration;
use bollard::container::LogOutput; use crate::host::{AttachedExec, ContainerTarget, ExecChunk};
use futures_util::StreamExt; use futures_util::StreamExt;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream}; use tokio::net::{TcpListener, TcpStream};
use tokio::task::{JoinHandle, JoinSet}; use tokio::task::{JoinHandle, JoinSet};
use crate::docker::exec::{create_attached_exec, AttachedExec};
use super::proc_net::PortFamily; use super::proc_net::PortFamily;
/// Buffer size for the host→container direction. OAuth callbacks are tiny; this /// Buffer size for the host→container direction. OAuth callbacks are tiny; this
@@ -143,13 +141,13 @@ impl Drop for PortForward {
} }
impl 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 /// 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 /// reported to the caller as an error rather than disappearing into a
/// background task. /// background task.
pub async fn bind( pub async fn bind(
container_id: String, container: ContainerTarget,
port: u16, port: u16,
family: PortFamily, family: PortFamily,
) -> Result<Self, std::io::Error> { ) -> Result<Self, std::io::Error> {
@@ -196,7 +194,7 @@ impl PortForward {
}; };
let target = family.socat_target(port); 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 { Ok(Self {
port, port,
@@ -219,7 +217,7 @@ impl PortForward {
/// Accept on both loopback listeners until aborted. Dropping this future drops /// Accept on both loopback listeners until aborted. Dropping this future drops
/// the listeners (freeing the port) and the `JoinSet` (aborting live tunnels). /// the listeners (freeing the port) and the `JoinSet` (aborting live tunnels).
async fn accept_loop( async fn accept_loop(
container_id: String, container: ContainerTarget,
port: u16, port: u16,
target: String, target: String,
v4: TcpListener, v4: TcpListener,
@@ -258,7 +256,7 @@ async fn accept_loop(
log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port); log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port);
let _ = stream.set_nodelay(true); let _ = stream.set_nodelay(true);
conns.spawn(tunnel_connection( conns.spawn(tunnel_connection(
container_id.clone(), container.clone(),
target.clone(), target.clone(),
stream, stream,
port, port,
@@ -288,7 +286,12 @@ async fn accept_optional(
/// ///
/// Nothing is forwarded until that decision is made, so a refused request never /// Nothing is forwarded until that decision is made, so a refused request never
/// reaches the container at all — not even a `docker exec`. /// 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 { let head = match read_leading_bytes(&mut stream).await {
Ok(head) => head, Ok(head) => head,
Err(e) => { 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. // 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. /// 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 /// 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 /// back on the wire. Passing them here keeps the byte stream exact, rather than
/// re-serialising a parsed request. /// 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( pub async fn tunnel_connection_with_prelude(
container_id: String, container_id: String,
target: String, target: String,
stream: TcpStream, stream: TcpStream,
port: u16, port: u16,
prelude: Vec<u8>, prelude: Vec<u8>,
) {
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<u8>,
) { ) {
let cmd = vec!["socat".to_string(), "-".to_string(), target.clone()]; let cmd = vec!["socat".to_string(), "-".to_string(), target.clone()];
@@ -524,7 +549,7 @@ pub async fn tunnel_connection_with_prelude(
mut output, mut output,
mut input, mut input,
.. ..
} = match create_attached_exec(&container_id, cmd, false).await { } = match container.attach_exec(cmd, false).await {
Ok(e) => e, Ok(e) => e,
Err(e) => { Err(e) => {
log::warn!( log::warn!(
@@ -587,12 +612,12 @@ pub async fn tunnel_connection_with_prelude(
// Only stdout is payload. The exec is created with tty = false // Only stdout is payload. The exec is created with tty = false
// precisely so Docker demultiplexes these, keeping socat's stderr // precisely so Docker demultiplexes these, keeping socat's stderr
// diagnostics out of the proxied byte stream. // diagnostics out of the proxied byte stream.
Ok(LogOutput::StdOut { message }) => { Ok(ExecChunk::StdOut(message)) => {
if host_tx.write_all(&message).await.is_err() { if host_tx.write_all(&message).await.is_err() {
break; break;
} }
} }
Ok(LogOutput::StdErr { message }) => { Ok(ExecChunk::StdErr(message)) => {
log::debug!( log::debug!(
"Auth bridge: socat stderr for port {}: {}", "Auth bridge: socat stderr for port {}: {}",
port, port,
+13 -6
View File
@@ -36,7 +36,7 @@
use serde::{Deserialize, Serialize}; 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 /// 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. /// 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 /// `npx @playwright/mcp`, is found as readily as one in
/// `/workspace/node_modules`. /// `/workspace/node_modules`.
pub async fn detect(container_id: &str) -> Result<PlaywrightDetection, String> { pub async fn detect(container_id: &str) -> Result<PlaywrightDetection, String> {
let output = exec_oneshot( detect_in(&ContainerTarget::docker(container_id)).await
container_id, }
vec!["node".to_string(), "-e".to_string(), PROBE.to_string()],
) /// [`detect`] against a target already in hand.
.await?; pub async fn detect_in(container: &ContainerTarget) -> Result<PlaywrightDetection, String> {
let output = container
.exec_oneshot(vec![
"node".to_string(),
"-e".to_string(),
PROBE.to_string(),
])
.await?;
parse_probe_output(&output) parse_probe_output(&output)
} }
+43 -34
View File
@@ -67,9 +67,7 @@ use serde::Serialize;
use tauri::AppHandle; use tauri::AppHandle;
use crate::commands::project_commands::emit_progress; use crate::commands::project_commands::emit_progress;
use crate::docker::exec::{ use crate::host::{AttachedExec, ContainerTarget, ExecSpec};
create_attached_exec_as, exec_oneshot_as, wait_for_exec_exit, AttachedExec,
};
use super::detect::{self, PlaywrightDetection}; use super::detect::{self, PlaywrightDetection};
@@ -242,13 +240,14 @@ pub async fn install_packages(
project_id: &str, project_id: &str,
container_id: &str, container_id: &str,
) -> Result<BrowserSetupOutcome, String> { ) -> Result<BrowserSetupOutcome, String> {
let container = ContainerTarget::docker(container_id);
emit_progress( emit_progress(
app, app,
project_id, project_id,
&format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR), &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 { if step.exit_code != 0 {
return Err(format!( return Err(format!(
"npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}", "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 // 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 // install has written the manifest, which is why this stays two commands
// rather than one. // 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)); 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 { if second.exit_code != 0 {
return Err(format!( return Err(format!(
"npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}", "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); step.log = merge_logs(step.log, second.log);
emit_progress(app, project_id, "Re-checking what the container has…"); 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 // 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 // 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( async fn npm_install(
app: &AppHandle, app: &AppHandle,
project_id: &str, project_id: &str,
container_id: &str, container: &ContainerTarget,
specs: &[&str], specs: &[&str],
) -> Result<StepResult, String> { ) -> Result<StepResult, String> {
let mut cmd = vec![ let mut cmd = vec![
@@ -348,7 +347,7 @@ async fn npm_install(
run_step( run_step(
app, app,
project_id, project_id,
container_id, container,
"claude", "claude",
INSTALL_DIR, INSTALL_DIR,
cmd, cmd,
@@ -363,20 +362,20 @@ async fn npm_install(
/// Read from the manifest npm just wrote rather than guessed, and falling back /// 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 /// to `@latest` when it can't be read — an unreadable manifest is a reason to
/// install something, not nothing. /// install something, not nothing.
async fn pinned_playwright_spec(container_id: &str) -> String { async fn pinned_playwright_spec(container: &ContainerTarget) -> String {
let script = format!( let script = format!(
"try{{const d=require('{}/node_modules/@playwright/cli/package.json').dependencies||{{}};\ "try{{const d=require('{}/node_modules/@playwright/cli/package.json').dependencies||{{}};\
process.stdout.write(d.playwright||'');}}catch(e){{}}", process.stdout.write(d.playwright||'');}}catch(e){{}}",
INSTALL_DIR INSTALL_DIR
); );
let (out, _code) = exec_oneshot_as( let (out, _code) = container
container_id, .exec_oneshot_as(
"claude", "claude",
vec!["node".to_string(), "-e".to_string(), script], vec!["node".to_string(), "-e".to_string(), script],
Vec::new(), Vec::new(),
) )
.await .await
.unwrap_or_default(); .unwrap_or_default();
let version: &str = out.trim(); let version: &str = out.trim();
// A version, not a range or a URL: anything else goes to the fallback // 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, container_id: &str,
target: BrowserTarget, target: BrowserTarget,
) -> Result<BrowserSetupOutcome, String> { ) -> Result<BrowserSetupOutcome, String> {
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 { let Some(cli) = before.playwright_cli.clone() else {
return Err( return Err(
"Playwright isn't installed in this container yet, so there is nothing to install a \ "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, project_id,
"Step 1/3 — checking whether this container already has the browser system libraries…", "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 { match &state {
LibraryState::Present => { LibraryState::Present => {
emit_progress( emit_progress(
@@ -482,7 +482,7 @@ pub async fn install_browser(
let deps = run_step( let deps = run_step(
app, app,
project_id, project_id,
container_id, &container,
"root", "root",
"/tmp", "/tmp",
vec![ vec![
@@ -530,7 +530,7 @@ pub async fn install_browser(
let dl = run_step( let dl = run_step(
app, app,
project_id, project_id,
container_id, &container,
user, user,
workdir, workdir,
vec![ vec![
@@ -559,7 +559,7 @@ pub async fn install_browser(
project_id, project_id,
&format!("Step 3/3 — checking that {} actually launches…", target.cli_name()), &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); push_section(&mut log, &verdict.detail);
if !verdict.ok { if !verdict.ok {
warning = merge( warning = merge(
@@ -590,7 +590,7 @@ pub async fn install_browser(
} }
emit_progress(app, project_id, "Re-checking what the container has…"); 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 { Ok(BrowserSetupOutcome {
detection, detection,
@@ -642,9 +642,12 @@ impl LibraryState {
/// dpkg's status file. A *missing* package on such an image is simply not in /// 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 /// any index, so apt fails, Playwright reports the failure, and this returns
/// [`LibraryState::Unknown`] — which installs, which is the right answer. /// [`LibraryState::Unknown`] — which installs, which is the right answer.
async fn check_libraries(container_id: &str, cli: &str, target: BrowserTarget) -> LibraryState { async fn check_libraries(
let run = exec_oneshot_as( container: &ContainerTarget,
container_id, cli: &str,
target: BrowserTarget,
) -> LibraryState {
let run = container.exec_oneshot_as(
"root", "root",
vec![ vec![
"node".to_string(), "node".to_string(),
@@ -708,7 +711,7 @@ struct LaunchVerdict {
/// Launch the browser headless, load one page, and close it. Seconds, and it is /// Launch the browser headless, load one page, and close it. Seconds, and it is
/// the only thing that distinguishes "downloaded" from "usable". /// the only thing that distinguishes "downloaded" from "usable".
async fn verify_launch( async fn verify_launch(
container_id: &str, container: &ContainerTarget,
detection: &PlaywrightDetection, detection: &PlaywrightDetection,
target: BrowserTarget, target: BrowserTarget,
) -> LaunchVerdict { ) -> LaunchVerdict {
@@ -724,8 +727,7 @@ async fn verify_launch(
}; };
let dir = manifest.trim_end_matches("package.json").trim_end_matches('/'); let dir = manifest.trim_end_matches("package.json").trim_end_matches('/');
let run = exec_oneshot_as( let run = container.exec_oneshot_as(
container_id,
"claude", "claude",
vec![ vec![
"node".to_string(), "node".to_string(),
@@ -884,13 +886,13 @@ impl StepResult {
/// Run one command in the container, streaming every line it prints to the pane /// Run one command in the container, streaming every line it prints to the pane
/// as `container-progress` and keeping the tail for the result. /// 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 /// `exec_oneshot`, because these commands run for minutes and the point is that
/// the user can watch them. /// the user can watch them.
async fn run_step( async fn run_step(
app: &AppHandle, app: &AppHandle,
project_id: &str, project_id: &str,
container_id: &str, container: &ContainerTarget,
user: &str, user: &str,
workdir: &str, workdir: &str,
cmd: Vec<String>, cmd: Vec<String>,
@@ -900,7 +902,14 @@ async fn run_step(
exec_id, exec_id,
mut output, mut output,
input, 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 // 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 // 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(Err(e)) => Err(e),
Ok(Ok(())) => Ok(StepResult { 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, log,
}), }),
} }
+55 -54
View File
@@ -50,7 +50,7 @@
//! | Trigger | Path | //! | Trigger | Path |
//! |---|---| //! |---|---|
//! | Turned off in the UI | `set_browser_view_enabled(false)` → persist `false`, then [`BrowserViewManager::stop`] | //! | 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 | //! | Project deleted | supervisor's `store.get()` check |
//! | Container rebuilt | old container stops → supervisor exits; the new one is not auto-started | //! | 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 | //! | 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 tokio::task::JoinHandle;
use crate::auth_bridge::proc_net::{self, PortFamily}; use crate::auth_bridge::proc_net::{self, PortFamily};
use crate::docker::container::is_container_running; use crate::host::ContainerTarget;
use crate::docker::exec::exec_oneshot;
use crate::storage::projects_store::ProjectsStore; use crate::storage::projects_store::ProjectsStore;
use detect::PlaywrightDetection; use detect::PlaywrightDetection;
@@ -300,7 +299,8 @@ impl BrowserViewManager {
return Ok(self.status(&project_id, true).await); 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() { if !detection.is_usable() {
let blocker = detection.blocker().unwrap_or_else(|| { let blocker = detection.blocker().unwrap_or_else(|| {
"Playwright is present but incomplete in this container.".to_string() "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 // 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: // session was using, so sessions don't walk up the range. Best-effort:
// a container with no dashboard makes this a no-op. // 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(); let token = generate_token();
// `--host 127.0.0.1` is ours to set, so the family is known and there is // `--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, Ok(p) => p,
Err(e) => { Err(e) => {
let _ = kill_dashboard(&container_id, &cli_entry).await; let _ = kill_dashboard(&container, &cli_entry).await;
return Err(e); return Err(e);
} }
}; };
@@ -354,7 +354,7 @@ impl BrowserViewManager {
let (cancel_tx, cancel_rx) = watch::channel(false); let (cancel_tx, cancel_rx) = watch::channel(false);
let supervisor = tokio::spawn(supervise( let supervisor = tokio::spawn(supervise(
project_id.clone(), project_id.clone(),
container_id.clone(), container,
cli_entry, cli_entry,
container_port, container_port,
epoch, epoch,
@@ -427,7 +427,7 @@ impl BrowserViewManager {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
async fn supervise( async fn supervise(
project_id: String, project_id: String,
container_id: String, container: ContainerTarget,
cli_entry: String, cli_entry: String,
container_port: u16, container_port: u16,
epoch: u64, epoch: u64,
@@ -443,7 +443,7 @@ async fn supervise(
log::info!("Browser view: project {} is gone — tearing down", project_id); log::info!("Browser view: project {} is gone — tearing down", project_id);
break; break;
} }
if !is_container_running(&container_id).await.unwrap_or(false) { if !container.is_running().await.unwrap_or(false) {
log::info!( log::info!(
"Browser view: container for project {} is no longer running — tearing down", "Browser view: container for project {} is no longer running — tearing down",
project_id project_id
@@ -459,7 +459,7 @@ async fn supervise(
// view never waits out an in-flight exec. // view never waits out an in-flight exec.
let alive = tokio::select! { let alive = tokio::select! {
_ = cancel.changed() => break, _ = 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 { if !alive {
log::warn!( log::warn!(
@@ -477,7 +477,7 @@ async fn supervise(
} }
proxy.shutdown().await; 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. // Deregister, unless a newer session has already taken this project's slot.
let superseded = { 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 /// 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), /// rather than waiting on an inherited stdout, and so a failure has a trail),
/// and readiness is established by [`wait_until_ready`] instead. /// 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 // `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 // launch; the container may have no egress, and we don't want to wait out a
// DNS timeout before the dashboard binds. // DNS timeout before the dashboard binds.
@@ -549,13 +553,11 @@ async fn launch_viewer(container_id: &str, cli_entry: &str, port: u16) -> Result
port, port,
VIEWER_LOG VIEWER_LOG
); );
exec_oneshot( container
container_id, .exec_oneshot(vec!["sh".to_string(), "-c".to_string(), script])
vec!["sh".to_string(), "-c".to_string(), script], .await
) .map(|_| ())
.await .map_err(|e| format!("Could not start the Playwright viewer: {}", e))
.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 /// 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"; const WORKDIR_PREFIX: &str = "cd /workspace 2>/dev/null || true";
/// Stop the dashboard daemon. Verified to free the port and stop answering. /// Stop the dashboard daemon. Verified to free the port and stop answering.
async fn kill_dashboard(container_id: &str, cli_entry: &str) -> Result<String, String> { async fn kill_dashboard(container: &ContainerTarget, cli_entry: &str) -> Result<String, String> {
let script = format!( let script = format!(
"{}; NO_UPDATE_NOTIFIER=1 node {} show --kill", "{}; NO_UPDATE_NOTIFIER=1 node {} show --kill",
WORKDIR_PREFIX, WORKDIR_PREFIX,
shell_quote(cli_entry) shell_quote(cli_entry)
); );
exec_oneshot( container
container_id, .exec_oneshot(vec!["sh".to_string(), "-c".to_string(), script])
vec!["sh".to_string(), "-c".to_string(), script], .await
)
.await
} }
/// Turn a failed start into something the user can act on. /// 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. /// Tail of the viewer's own output, for a start that didn't come up.
async fn read_viewer_log(container_id: &str) -> String { async fn read_viewer_log(container: &ContainerTarget) -> String {
exec_oneshot( container
container_id, .exec_oneshot(vec![
vec!["tail".to_string(), "-n".to_string(), "40".to_string(), VIEWER_LOG.to_string()], "tail".to_string(),
) "-n".to_string(),
.await "40".to_string(),
.unwrap_or_default() 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 /// 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 /// downstream — the host proxy is loopback-only and token-gated, and the pane's
/// iframe is sandboxed — not this function. /// 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<u16> = Vec::new(); let mut tried: Vec<u16> = Vec::new();
let mut last: Option<String> = None; let mut last: Option<String> = None;
for _ in 0..MAX_PORT_ATTEMPTS { for _ in 0..MAX_PORT_ATTEMPTS {
// Re-read the listener snapshot each attempt: the port that was free a // Re-read the listener snapshot each attempt: the port that was free a
// moment ago is exactly the one we may have just lost. // 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, Ok(p) => p,
Err(e) => { Err(e) => {
// Report why the *attempts* failed, not just "nothing free": // 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); 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 // 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 // there — see `probe_entry_path` for why that matters. This, not the
// launcher's stdout, is the readiness signal: verified that the // launcher's stdout, is the readiness signal: verified that the
// "Listening on …" line is printed only on the very first start. // "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)), Ok(path) => return Ok((port, path)),
Err(e) => { 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 // Always kill before retrying: the dashboard is a singleton, so
// a launcher that came up on some *other* port would otherwise // a launcher that came up on some *other* port would otherwise
// make every further attempt a no-op that silently ignores the // make every further attempt a no-op that silently ignores the
// port we asked for. // 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)); 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 /// First port in [`VIEWER_PORTS`] that nothing in the container is listening on
/// and that this start has not already tried. /// and that this start has not already tried.
async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result<u16, String> { async fn pick_viewer_port(container: &ContainerTarget, tried: &[u16]) -> Result<u16, String> {
let text = exec_oneshot( let text = container
container_id, .exec_oneshot(vec![
vec![
// Absolute path, deliberately, for the same reason the auth bridge // Absolute path, deliberately, for the same reason the auth bridge
// uses one: `container/Dockerfile` puts a container-writable // uses one: `container/Dockerfile` puts a container-writable
// directory first on `PATH`, so a bare `cat` is a name the container // 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<u16, Stri
"/usr/bin/cat".to_string(), "/usr/bin/cat".to_string(),
"/proc/net/tcp".to_string(), "/proc/net/tcp".to_string(),
"/proc/net/tcp6".to_string(), "/proc/net/tcp6".to_string(),
], ])
) .await
.await .unwrap_or_default();
.unwrap_or_default();
let taken = proc_net::parse_loopback_listeners(&text); let taken = proc_net::parse_loopback_listeners(&text);
VIEWER_PORTS VIEWER_PORTS
.clone() .clone()
@@ -722,10 +726,10 @@ async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result<u16, Stri
} }
/// Poll the viewer until it answers, and return the path the pane should load. /// Poll the viewer until it answers, and return the path the pane should load.
async fn wait_until_ready(container_id: &str, port: u16) -> Result<String, String> { async fn wait_until_ready(container: &ContainerTarget, port: u16) -> Result<String, String> {
let deadline = tokio::time::Instant::now() + READY_TIMEOUT; let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
loop { 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), Ok(path) => return Ok(path),
Err(e) => e, 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 /// 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. /// 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 `/`. /// A `200` (no redirect) is fine too; then the entry point is just `/`.
async fn probe_entry_path(container_id: &str, port: u16) -> Result<String, String> { async fn probe_entry_path(container: &ContainerTarget, port: u16) -> Result<String, String> {
// The request is bounded on both sides. Verified: the dashboard answers a // The request is bounded on both sides. Verified: the dashboard answers a
// bad WebSocket path by holding the socket open forever rather than // 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 — // 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<String, Strin
); );
let out = tokio::time::timeout( let out = tokio::time::timeout(
PROBE_TIMEOUT * 2, PROBE_TIMEOUT * 2,
exec_oneshot( container.exec_oneshot(vec!["node".to_string(), "-e".to_string(), script]),
container_id,
vec!["node".to_string(), "-e".to_string(), script],
),
) )
.await .await
.map_err(|_| "the viewer probe did not return".to_string())??; .map_err(|_| "the viewer probe did not return".to_string())??;
+25 -25
View File
@@ -37,7 +37,7 @@ use serde::{Deserialize, Serialize};
use tauri::AppHandle; use tauri::AppHandle;
use crate::commands::project_commands::emit_progress; use crate::commands::project_commands::emit_progress;
use crate::docker::exec::exec_oneshot_as; use crate::host::ContainerTarget;
use super::detect::PlaywrightDetection; use super::detect::PlaywrightDetection;
@@ -150,14 +150,14 @@ pub async fn open(
shell_quote(&script), shell_quote(&script),
HELPER_LOG HELPER_LOG
); );
exec_oneshot_as( ContainerTarget::docker(container_id)
container_id, .exec_oneshot_as(
"claude", "claude",
vec!["sh".to_string(), "-c".to_string(), launcher], vec!["sh".to_string(), "-c".to_string(), launcher],
Vec::new(), Vec::new(),
) )
.await .await
.map_err(|e| format!("Could not start the browser helper: {}", e))?; .map_err(|e| format!("Could not start the browser helper: {}", e))?;
emit_progress(app, project_id, "Waiting for the page to load…"); emit_progress(app, project_id, "Waiting for the page to load…");
wait_until_ready(container_id).await wait_until_ready(container_id).await
@@ -189,13 +189,13 @@ pub async fn state(container_id: &str) -> PageState {
"try{{process.stdout.write(require('fs').readFileSync('{}','utf8'));}}catch(e){{}}", "try{{process.stdout.write(require('fs').readFileSync('{}','utf8'));}}catch(e){{}}",
STATE_PATH STATE_PATH
); );
let Ok((out, _)) = exec_oneshot_as( let Ok((out, _)) = ContainerTarget::docker(container_id)
container_id, .exec_oneshot_as(
"claude", "claude",
vec!["node".to_string(), "-e".to_string(), script], vec!["node".to_string(), "-e".to_string(), script],
Vec::new(), Vec::new(),
) )
.await .await
else { else {
return PageState::default(); return PageState::default();
}; };
@@ -209,15 +209,15 @@ async fn write_control(container_id: &str, json: String) -> Result<(), String> {
"require('fs').writeFileSync('{}',process.argv[1]);", "require('fs').writeFileSync('{}',process.argv[1]);",
CONTROL_PATH CONTROL_PATH
); );
exec_oneshot_as( ContainerTarget::docker(container_id)
container_id, .exec_oneshot_as(
"claude", "claude",
vec!["node".to_string(), "-e".to_string(), script, json], vec!["node".to_string(), "-e".to_string(), script, json],
Vec::new(), Vec::new(),
) )
.await .await
.map(|_| ()) .map(|_| ())
.map_err(|e| format!("Could not reach the browser helper: {}", e)) .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. /// Wait for a *running* helper to report the URL we just asked it for.
@@ -76,8 +76,7 @@ use tauri::{AppHandle, Emitter, State};
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use tokio::sync::{mpsc, oneshot, Mutex}; use tokio::sync::{mpsc, oneshot, Mutex};
use crate::docker::container::is_container_running; use crate::host::{AttachedExec, ContainerTarget};
use crate::docker::exec::{create_attached_exec, wait_for_exec_exit, AttachedExec};
use crate::storage::secure; use crate::storage::secure;
use crate::AppState; use crate::AppState;
@@ -867,7 +866,7 @@ async fn type_code_then_enter<W: tokio::io::AsyncWrite + Unpin>(
async fn run_setup_token( async fn run_setup_token(
app: &AppHandle, app: &AppHandle,
project_id: &str, project_id: &str,
container_id: &str, target: &ContainerTarget,
mut input_rx: mpsc::UnboundedReceiver<Vec<u8>>, mut input_rx: mpsc::UnboundedReceiver<Vec<u8>>,
mut cancel_rx: oneshot::Receiver<()>, mut cancel_rx: oneshot::Receiver<()>,
) -> Result<String, String> { ) -> Result<String, String> {
@@ -877,16 +876,16 @@ async fn run_setup_token(
exec_id, exec_id,
mut output, mut output,
mut input, mut input,
} = create_attached_exec( } = target
container_id, .attach_exec(
vec![ vec![
"sh".to_string(), "sh".to_string(),
"-c".to_string(), "-c".to_string(),
SETUP_TOKEN_SCRIPT.to_string(), SETUP_TOKEN_SCRIPT.to_string(),
], ],
true, true,
) )
.await?; .await?;
let mut stripper = AnsiStripper::default(); let mut stripper = AnsiStripper::default();
let mut redactor = SecretRedactor::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 // 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 // printed a token succeeded whatever Docker says about it — but it is worth
// saying so rather than silently calling it a clean exit. // 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(0) => {}
Some(code) => { Some(code) => {
return Err(format!( return Err(format!(
@@ -1091,13 +1090,13 @@ pub async fn acquire_claude_token(
.get(&project_id) .get(&project_id)
.ok_or_else(|| format!("Project {} not found", 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!( format!(
"Project '{}' has no container yet. Start it, then run authentication again.", "Project '{}' has no container yet. Start it, then run authentication again.",
project.name project.name
) )
})?; })?;
if !is_container_running(&container_id).await.unwrap_or(false) { if !target.is_running().await.unwrap_or(false) {
return Err(format!( return Err(format!(
"The container for '{}' is not running. Start it, then run authentication again.", "The container for '{}' is not running. Start it, then run authentication again.",
project.name 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.", "Running `claude setup-token` — sign in at the URL below, then submit the code it gives you.",
); );
let result = let result = run_setup_token(&app_handle, &project_id, &target, input_rx, cancel_rx).await;
run_setup_token(&app_handle, &project_id, &container_id, input_rx, cancel_rx).await;
// Release the flow. Nothing else needs unwinding: this command changes no // Release the flow. Nothing else needs unwinding: this command changes no
// persisted state until the token itself is stored, which is the point. // persisted state until the token itself is stored, which is the point.
+95 -145
View File
@@ -4,14 +4,11 @@ use std::sync::Arc;
use base64::engine::general_purpose::STANDARD as BASE64; use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _; use base64::Engine as _;
use bollard::container::{DownloadFromContainerOptions, LogOutput};
use bollard::exec::{CreateExecOptions, StartExecResults};
use futures_util::StreamExt; use futures_util::StreamExt;
use serde::Serialize; use serde::Serialize;
use tauri::State; use tauri::State;
use crate::docker::client::get_docker; use crate::host::{ContainerTarget, ExecChunk, ExecSpec, OUTPUT_LIMIT_MARKER};
use crate::docker::exec::{exec_oneshot_as, exec_oneshot_streams_as, OUTPUT_LIMIT_MARKER};
use crate::AppState; use crate::AppState;
#[derive(Debug, PartialEq, Serialize)] #[derive(Debug, PartialEq, Serialize)]
@@ -63,9 +60,7 @@ pub async fn list_container_files(
.get(&project_id) .get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?; .ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project let target = ContainerTarget::for_project(&project)
.container_id
.as_ref()
.ok_or_else(|| "Container not running".to_string())?; .ok_or_else(|| "Container not running".to_string())?;
// `exec_oneshot` discards the exit code, which is how a `find` that listed // `exec_oneshot` discards the exit code, which is how a `find` that listed
@@ -78,7 +73,8 @@ pub async fn list_container_files(
// are the error message, its `-printf` records are the listing, and the // are the error message, its `-printf` records are the listing, and the
// parser should never be handed the former. // parser should never be handed the former.
let (records, diagnostics, code) = let (records, diagnostics, code) =
exec_oneshot_streams_as(container_id, "claude", list_argv(&path), Vec::new()) target
.exec_oneshot_streams_as("claude", list_argv(&path), Vec::new())
.await .await
.map_err(|e| describe_listing_failure(&path, e))?; .map_err(|e| describe_listing_failure(&path, e))?;
@@ -156,7 +152,7 @@ fn list_argv(path: &str) -> Vec<String> {
/// folder can act on. /// folder can act on.
/// ///
/// One case is worth naming: a directory with more entries than /// One case is worth naming: a directory with more entries than
/// [`crate::docker::exec::MAX_ONESHOT_OUTPUT`] will hold. Roughly 100k names is /// [`crate::host::MAX_ONESHOT_OUTPUT`] will hold. Roughly 100k names is
/// the point where a `find` record set passes 8 MiB, and what the panel showed /// the point where a `find` record set passes 8 MiB, and what the panel showed
/// was "Command output exceeded 8388608 bytes and was abandoned" — a true /// was "Command output exceeded 8388608 bytes and was abandoned" — a true
/// statement about a buffer, and no help at all about a directory. /// statement about a buffer, and no help at all about a directory.
@@ -430,7 +426,11 @@ pub(crate) fn validate_container_write_path(what: &str, path: &str) -> Result<()
/// and the lexical answer stands: failing every write closed would break the /// and the lexical answer stands: failing every write closed would break the
/// panel outright for a risk the container user does not need this code path to /// panel outright for a risk the container user does not need this code path to
/// take. /// take.
async fn resolve_container_dir(container_id: &str, what: &str, dir: &str) -> Result<(), String> { async fn resolve_container_dir(
target: &ContainerTarget,
what: &str,
dir: &str,
) -> Result<(), String> {
validate_container_write_path(what, dir)?; validate_container_write_path(what, dir)?;
// Split streams, not the combined buffer: `realpath`'s answer is a *path* // Split streams, not the combined buffer: `realpath`'s answer is a *path*
@@ -438,18 +438,18 @@ async fn resolve_container_dir(container_id: &str, what: &str, dir: &str) -> Res
// hazard the listing above took apart for `find`. A warning on stderr — // hazard the listing above took apart for `find`. A warning on stderr —
// and there is one whenever a component is unreadable — used to be spliced // and there is one whenever a component is unreadable — used to be spliced
// into the string this then compared against the write roots. // into the string this then compared against the write roots.
let (stdout, diagnostics, code) = exec_oneshot_streams_as( let (stdout, diagnostics, code) = target
container_id, .exec_oneshot_streams_as(
"claude", "claude",
vec![ vec![
"realpath".to_string(), "realpath".to_string(),
"-m".to_string(), "-m".to_string(),
"--".to_string(), "--".to_string(),
dir.to_string(), dir.to_string(),
], ],
Vec::new(), Vec::new(),
) )
.await?; .await?;
let resolved = stdout.trim(); let resolved = stdout.trim();
if code != 0 || resolved.is_empty() { if code != 0 || resolved.is_empty() {
@@ -1188,7 +1188,7 @@ pub(crate) struct FetchedFile {
/// Fetch a single regular file from a container as exact bytes. /// Fetch a single regular file from a container as exact bytes.
/// ///
/// The viewer's read. It deliberately goes through Docker's archive endpoint rather than /// The viewer's read. It deliberately goes through the archive endpoint rather than
/// `exec_oneshot`: that reader runs every chunk through `String::from_utf8_lossy` /// `exec_oneshot`: that reader runs every chunk through `String::from_utf8_lossy`
/// and merges stderr into stdout, so it would both corrupt any non-UTF-8 file /// and merges stderr into stdout, so it would both corrupt any non-UTF-8 file
/// and be able to splice diagnostics into what the caller believes is content. /// and be able to splice diagnostics into what the caller believes is content.
@@ -1202,19 +1202,15 @@ pub(crate) struct FetchedFile {
/// file — or the whole *directory tree*, since the type check happens after the /// file — or the whole *directory tree*, since the type check happens after the
/// read — landed in host RAM twice. This function buffers, so every caller of /// read — landed in host RAM twice. This function buffers, so every caller of
/// it must name a ceiling. /// it must name a ceiling.
///
/// Takes a container id rather than a [`ContainerTarget`] because the file
/// viewer's commands call it with the id they already hold.
pub(crate) async fn fetch_container_file( pub(crate) async fn fetch_container_file(
container_id: &str, container_id: &str,
container_path: &str, container_path: &str,
max_bytes: u64, max_bytes: u64,
) -> Result<FetchedFile, String> { ) -> Result<FetchedFile, String> {
let docker = get_docker()?; let mut stream = ContainerTarget::docker(container_id).download_archive(container_path);
let mut stream = docker.download_from_container(
container_id,
Some(DownloadFromContainerOptions {
path: container_path.to_string(),
}),
);
// A tar member is a 512-byte header plus payload padded to 512. 8 KiB of // A tar member is a 512-byte header plus payload padded to 512. 8 KiB of
// slack past the payload cap guarantees the header and the whole capped // slack past the payload cap guarantees the header and the whole capped
@@ -1297,13 +1293,11 @@ pub async fn read_container_file(
.get(&project_id) .get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?; .ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project let target = ContainerTarget::for_project(&project)
.container_id
.as_ref()
.ok_or_else(|| "Container not running".to_string())?; .ok_or_else(|| "Container not running".to_string())?;
let cap = max_bytes.unwrap_or(MAX_READ_BYTES).min(MAX_READ_BYTES); let cap = max_bytes.unwrap_or(MAX_READ_BYTES).min(MAX_READ_BYTES);
let fetched = fetch_container_file(container_id, &path, cap).await?; let fetched = fetch_container_file(target.id(), &path, cap).await?;
Ok(FileContents { Ok(FileContents {
contents_base64: BASE64.encode(&fetched.bytes), contents_base64: BASE64.encode(&fetched.bytes),
@@ -1333,9 +1327,7 @@ pub async fn rename_container_path(
.get(&project_id) .get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?; .ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project let target = ContainerTarget::for_project(&project)
.container_id
.as_ref()
.ok_or_else(|| "Container not running".to_string())?; .ok_or_else(|| "Container not running".to_string())?;
// The name is checked by `validate_entry_name`; the path it is applied to // The name is checked by `validate_entry_name`; the path it is applied to
@@ -1349,7 +1341,7 @@ pub async fn rename_container_path(
// directory component to resolve. // directory component to resolve.
let parent = parent_dir(&from_path); let parent = parent_dir(&from_path);
if parent != "/" { if parent != "/" {
resolve_container_dir(container_id, "Item", &parent).await?; resolve_container_dir(&target, "Item", &parent).await?;
} }
let new_name = to_path.trim(); let new_name = to_path.trim();
@@ -1364,8 +1356,7 @@ pub async fn rename_container_path(
// and exits 0 — so `-n` on its own would report a rename that never // and exits 0 — so `-n` on its own would report a rename that never
// happened. The existence check is what turns it into an error the user // happened. The existence check is what turns it into an error the user
// sees; `-n` stays as the belt-and-braces against the race between them. // sees; `-n` stays as the belt-and-braces against the race between them.
let (_, exists) = exec_oneshot_as( let (_, exists) = target.exec_oneshot_as(
container_id,
"claude", "claude",
vec!["test".to_string(), "-e".to_string(), dest.clone()], vec!["test".to_string(), "-e".to_string(), dest.clone()],
Vec::new(), Vec::new(),
@@ -1375,8 +1366,7 @@ pub async fn rename_container_path(
return Err(format!("\"{}\" already exists in this folder", new_name)); return Err(format!("\"{}\" already exists in this folder", new_name));
} }
let (output, code) = exec_oneshot_as( let (output, code) = target.exec_oneshot_as(
container_id,
"claude", "claude",
vec![ vec![
"mv".to_string(), "mv".to_string(),
@@ -1417,19 +1407,16 @@ pub async fn create_container_directory(
.get(&project_id) .get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?; .ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project let target = ContainerTarget::for_project(&project)
.container_id
.as_ref()
.ok_or_else(|| "Container not running".to_string())?; .ok_or_else(|| "Container not running".to_string())?;
resolve_container_dir(container_id, "Folder", &parent_path).await?; resolve_container_dir(&target, "Folder", &parent_path).await?;
let name = name.trim(); let name = name.trim();
validate_entry_name(name)?; validate_entry_name(name)?;
let dest = join_path(&parent_path, name); let dest = join_path(&parent_path, name);
let (output, code) = exec_oneshot_as( let (output, code) = target.exec_oneshot_as(
container_id,
"claude", "claude",
vec!["mkdir".to_string(), "--".to_string(), dest.clone()], vec!["mkdir".to_string(), "--".to_string(), dest.clone()],
Vec::new(), Vec::new(),
@@ -1459,20 +1446,19 @@ pub(crate) fn not_running_message(action: &str, why: &str) -> String {
/// Refuse, in a sentence, before a Docker error has to speak for us. /// Refuse, in a sentence, before a Docker error has to speak for us.
/// ///
/// Both file transfers and the backup run through `docker exec`, which needs a /// Both file transfers and the backup run through `docker exec`, which needs a
/// running container. Without this the failure surfaces as bollard's /// running container. Without this the failure surfaces as Docker's
/// `is not running` wrapped in whatever the caller was doing, and for the /// `is not running` wrapped in whatever the caller was doing, and for the
/// upload it surfaces even less usefully: `resolve_container_dir`'s `realpath` /// upload it surfaces even less usefully: `resolve_container_dir`'s `realpath`
/// is the first thing to touch the container, so a stopped project fails inside /// is the first thing to touch the container, so a stopped project fails inside
/// path *validation* and reads like the path was the problem. /// path *validation* and reads like the path was the problem.
///
/// Takes a container id rather than a [`ContainerTarget`] because the file
/// viewer's commands call it with the id they already hold.
pub(crate) async fn require_running(container_id: &str, action: &str) -> Result<(), String> { pub(crate) async fn require_running(container_id: &str, action: &str) -> Result<(), String> {
let docker = get_docker()?; // An unreachable daemon is an error; an inspect failure (container gone)
let running = docker // reads as "not running" — `is_running`'s contract, and what this did
.inspect_container(container_id, None) // when it inspected the container itself.
.await let running = ContainerTarget::docker(container_id).is_running().await?;
.ok()
.and_then(|info| info.state)
.and_then(|s| s.running)
.unwrap_or(false);
if running { if running {
return Ok(()); return Ok(());
} }
@@ -1543,12 +1529,10 @@ pub async fn download_container_file(
.get(&project_id) .get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?; .ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project let target = ContainerTarget::for_project(&project)
.container_id
.as_ref()
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?; .ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
require_running(container_id, "saving a file to the host").await?; require_running(target.id(), "saving a file to the host").await?;
let Some(chosen) = pick_save_path(&window, &suggested_save_name(&container_path)).await else { let Some(chosen) = pick_save_path(&window, &suggested_save_name(&container_path)).await else {
// Dismissed. Not an error, and deliberately distinguishable from one: // Dismissed. Not an error, and deliberately distinguishable from one:
@@ -1564,8 +1548,6 @@ pub async fn download_container_file(
// costs. See the note on `pick_save_path` about which ones those are. // costs. See the note on `pick_save_path` about which ones those are.
let dest = resolve_host_path(&chosen, HostPathUse::WriteChosenName).await?; let dest = resolve_host_path(&chosen, HostPathUse::WriteChosenName).await?;
let docker = get_docker()?;
// `$TC_SRC`, never argv interpolation: the path reaches the shell as an // `$TC_SRC`, never argv interpolation: the path reaches the shell as an
// environment value, so a name containing a quote or a `$` is data. // environment value, so a name containing a quote or a `$` is data.
// //
@@ -1627,30 +1609,19 @@ esac
echo "TC_SIZE=$SZ" >&2 echo "TC_SIZE=$SZ" >&2
dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5"#; dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5"#;
let exec = docker // As `claude`, no TTY, no stdin and no working directory (the image's
.create_exec( // default) — the options this exec has always been created with.
container_id, let exec = target
CreateExecOptions { .attach(
attach_stdout: Some(true), ExecSpec::new(vec!["sh".to_string(), "-c".to_string(), script.to_string()])
attach_stderr: Some(true), .env(vec![format!("TC_SRC={}", container_path)])
cmd: Some(vec!["sh".to_string(), "-c".to_string(), script.to_string()]), .working_dir(None)
env: Some(vec![format!("TC_SRC={}", container_path)]), .no_stdin(),
user: Some("claude".to_string()),
..Default::default()
},
) )
.await .await
.map_err(|e| format!("Failed to start download: {}", e))?; .map_err(|e| format!("Failed to start download: {}", e))?;
let exec_id = exec.exec_id;
let result = docker let mut output = exec.output;
.start_exec(&exec.id, None)
.await
.map_err(|e| format!("Failed to start download: {}", e))?;
let mut output = match result {
StartExecResults::Attached { output, .. } => output,
StartExecResults::Detached => return Err("Download exec started detached".to_string()),
};
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
let partial = partial_download_path(&dest)?; let partial = partial_download_path(&dest)?;
@@ -1696,7 +1667,7 @@ dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5"#;
while let Some(msg) = output.next().await { while let Some(msg) = output.next().await {
match msg { match msg {
Ok(LogOutput::StdOut { message }) => { Ok(ExecChunk::StdOut(message)) => {
if let Err(e) = writer.write_all(&message).await { if let Err(e) = writer.write_all(&message).await {
stream_err = Some(format!("Failed to write {}: {}", dest.display(), e)); stream_err = Some(format!("Failed to write {}: {}", dest.display(), e));
break; break;
@@ -1727,7 +1698,7 @@ dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5"#;
} }
} }
} }
Ok(LogOutput::StdErr { message }) => { Ok(ExecChunk::StdErr(message)) => {
push_capped(&mut stderr_text, &message); push_capped(&mut stderr_text, &message);
if declared.is_none() { if declared.is_none() {
declared = parse_declared_size(&stderr_text); declared = parse_declared_size(&stderr_text);
@@ -1760,7 +1731,7 @@ dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5"#;
// success renames a truncated partial over the file the user already had // success renames a truncated partial over the file the user already had
// and reports the byte count as if it were the whole thing. That is the one // and reports the byte count as if it were the whole thing. That is the one
// way this command could destroy data, so silence is failure. // way this command could destroy data, so silence is failure.
let exit_code = crate::docker::exec::wait_for_exec_exit(&exec.id).await; let exit_code = target.wait_exec_exit(&exec_id).await;
if stream_err.is_none() && exit_code != Some(0) { if stream_err.is_none() && exit_code != Some(0) {
// Framed and clipped, never verbatim. // Framed and clipped, never verbatim.
// //
@@ -1894,7 +1865,7 @@ pub struct UploadOutcome {
/// * the destination goes through [`resolve_container_dir`], so it is inside /// * the destination goes through [`resolve_container_dir`], so it is inside
/// [`CONTAINER_WRITE_ROOTS`] both as written and as it resolves; /// [`CONTAINER_WRITE_ROOTS`] both as written and as it resolves;
/// * the read, the size ceiling and the descriptor check are /// * the read, the size ceiling and the descriptor check are
/// `docker::exec::upload_host_file_with_ids`', unchanged — including /// `ContainerTarget::upload_host_file_with_ids`', unchanged — including
/// landing the file owned by the container user rather than root. /// landing the file owned by the container user rather than root.
/// ///
/// The dialog itself is opened from Rust ([`pick_files_to_upload`]), which is /// The dialog itself is opened from Rust ([`pick_files_to_upload`]), which is
@@ -1911,15 +1882,13 @@ pub async fn upload_files_to_container(
.get(&project_id) .get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?; .ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project let target = ContainerTarget::for_project(&project)
.container_id
.as_ref()
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?; .ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
// Both checks before the dialog, so a project that cannot receive files // Both checks before the dialog, so a project that cannot receive files
// says so instead of asking the user to choose some first. // says so instead of asking the user to choose some first.
require_running(container_id, "uploading files").await?; require_running(target.id(), "uploading files").await?;
resolve_container_dir(container_id, "Upload", &container_dir).await?; resolve_container_dir(&target, "Upload", &container_dir).await?;
let Some(picked) = pick_files_to_upload(&window).await else { let Some(picked) = pick_files_to_upload(&window).await else {
return Ok(None); return Ok(None);
@@ -1930,14 +1899,14 @@ pub async fn upload_files_to_container(
// files; this one is the check that actually guards the extraction. A modal // files; this one is the check that actually guards the extraction. A modal
// has no time limit, and `resolve_container_dir` answers a question about // has no time limit, and `resolve_container_dir` answers a question about
// the container's filesystem — which the container is free to change while // the container's filesystem — which the container is free to change while
// the dialog is open. Without this, `docker::exec`'s doc comment claim that // the dialog is open. Without this, the uploader's doc comment claim that
// the destination "has already been confirmed" would be true only of a // the destination "has already been confirmed" would be true only of a
// moment that had passed. // moment that had passed.
resolve_container_dir(container_id, "Upload", &container_dir).await?; resolve_container_dir(&target, "Upload", &container_dir).await?;
// Once for the whole selection, not once per file — see // Once for the whole selection, not once per file — see
// `upload_host_file_with_ids`. // `upload_host_file_with_ids`.
let ids = crate::docker::exec::container_user_ids(container_id).await; let ids = target.user_ids().await;
let mut outcome = UploadOutcome { let mut outcome = UploadOutcome {
uploaded: Vec::new(), uploaded: Vec::new(),
@@ -1951,7 +1920,7 @@ pub async fn upload_files_to_container(
continue; continue;
} }
}; };
match upload_one(container_id, &path, &container_dir, ids).await { match upload_one(&target, &path, &container_dir, ids).await {
Ok(dest) => { Ok(dest) => {
log::info!("Uploaded {} into project {} at {}", path, project_id, dest); log::info!("Uploaded {} into project {} at {}", path, project_id, dest);
outcome.uploaded.push(dest); outcome.uploaded.push(dest);
@@ -1966,7 +1935,7 @@ pub async fn upload_files_to_container(
/// naming the file, because the caller may be reporting several at once and /// naming the file, because the caller may be reporting several at once and
/// "is a folder" on its own does not say which one. /// "is a folder" on its own does not say which one.
async fn upload_one( async fn upload_one(
container_id: &str, target: &ContainerTarget,
host_path: &str, host_path: &str,
container_dir: &str, container_dir: &str,
ids: (u64, u64), ids: (u64, u64),
@@ -1990,7 +1959,7 @@ async fn upload_one(
// The ceiling is enforced against the open descriptor inside the uploader; // The ceiling is enforced against the open descriptor inside the uploader;
// this copy of it exists so the refusal arrives as a sentence instead of // this copy of it exists so the refusal arrives as a sentence instead of
// after a 300 MB read. // after a 300 MB read.
use crate::docker::exec::MAX_DROP_BYTES; use crate::host::MAX_DROP_BYTES;
if meta.len() > MAX_DROP_BYTES { if meta.len() > MAX_DROP_BYTES {
return Err(format!( return Err(format!(
"{} is too large to upload ({:.0} MB; limit {} MB). Mount it into the project instead.", "{} is too large to upload ({:.0} MB; limit {} MB). Mount it into the project instead.",
@@ -2000,14 +1969,9 @@ async fn upload_one(
)); ));
} }
crate::docker::exec::upload_host_file_with_ids( target
container_id, .upload_host_file_with_ids(&resolved, container_dir, &name, ids)
&resolved, .await
container_dir,
&name,
ids,
)
.await
} }
/// Container-authored diagnostic text, cut down to something safe to quote. /// Container-authored diagnostic text, cut down to something safe to quote.
@@ -2274,14 +2238,10 @@ pub async fn download_container_backup(
.get(&project_id) .get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?; .ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project let target = ContainerTarget::for_project(&project)
.container_id
.as_ref()
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?; .ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
let docker = get_docker()?; require_running(target.id(), "backing up").await?;
require_running(container_id, "backing up").await?;
let path = container_path.unwrap_or_else(|| "/workspace".to_string()); let path = container_path.unwrap_or_else(|| "/workspace".to_string());
// Read-only source: `tar -C` it, so absoluteness and `..` are what matter. // Read-only source: `tar -C` it, so absoluteness and `..` are what matter.
@@ -2326,33 +2286,22 @@ tar czf - --ignore-failed-read \
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()]; let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
let exec = docker // As `claude`, no TTY, no stdin and no working directory (the image's
.create_exec( // default) — the options this exec has always been created with.
container_id, let exec = target
CreateExecOptions { .attach(
attach_stdout: Some(true), ExecSpec::new(cmd)
attach_stderr: Some(true), .env(vec![
cmd: Some(cmd),
env: Some(vec![
"HOME=/home/claude".to_string(), "HOME=/home/claude".to_string(),
format!("TC_BACKUP_SRC={}", path), format!("TC_BACKUP_SRC={}", path),
]), ])
user: Some("claude".to_string()), .working_dir(None)
..Default::default() .no_stdin(),
},
) )
.await .await
.map_err(|e| format!("Failed to create backup exec: {}", e))?; .map_err(|e| format!("Failed to start backup: {}", e))?;
let exec_id = exec.exec_id;
let result = docker let mut output = exec.output;
.start_exec(&exec.id, None)
.await
.map_err(|e| format!("Failed to start backup exec: {}", e))?;
let mut output = match result {
StartExecResults::Attached { output, .. } => output,
StartExecResults::Detached => return Err("Backup exec started detached".to_string()),
};
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
let partial = partial_download_path(&dest)?; let partial = partial_download_path(&dest)?;
@@ -2401,14 +2350,14 @@ tar czf - --ignore-failed-read \
while let Some(msg) = output.next().await { while let Some(msg) = output.next().await {
match msg { match msg {
Ok(LogOutput::StdOut { message }) => { Ok(ExecChunk::StdOut(message)) => {
if let Err(e) = writer.write_all(&message).await { if let Err(e) = writer.write_all(&message).await {
stream_err = Some(format!("Failed to write backup file: {}", e)); stream_err = Some(format!("Failed to write backup file: {}", e));
break; break;
} }
total += message.len() as u64; total += message.len() as u64;
} }
Ok(LogOutput::StdErr { message }) => { Ok(ExecChunk::StdErr(message)) => {
push_capped(&mut stderr_text, &message); push_capped(&mut stderr_text, &message);
} }
Ok(_) => {} Ok(_) => {}
@@ -2430,7 +2379,7 @@ tar czf - --ignore-failed-read \
// when `total > 0`. Poll until the exec actually reports finished so the // when `total > 0`. Poll until the exec actually reports finished so the
// exit code is reliably populated; if it can't be determined we fall back to // exit code is reliably populated; if it can't be determined we fall back to
// the `total == 0` check below. // the `total == 0` check below.
let exit_code = crate::docker::exec::wait_for_exec_exit(&exec.id).await; let exit_code = target.wait_exec_exit(&exec_id).await;
if stream_err.is_none() && exit_code.is_some_and(|c| c != 0) { if stream_err.is_none() && exit_code.is_some_and(|c| c != 0) {
stream_err = Some(format!( stream_err = Some(format!(
@@ -3564,10 +3513,11 @@ mod tests {
// `/workspace/escape` is under a write root as a string and is `/etc` as // `/workspace/escape` is under a write root as a string and is `/etc` as
// a location. // a location.
let escaped = resolve_container_dir(&name, "Folder", "/workspace/escape").await; let target = ContainerTarget::docker(name.clone());
let escaped = resolve_container_dir(&target, "Folder", "/workspace/escape").await;
println!("container escape: {:?}", escaped); println!("container escape: {:?}", escaped);
assert!(escaped.is_err(), "a symlink out of /workspace was accepted"); assert!(escaped.is_err(), "a symlink out of /workspace was accepted");
resolve_container_dir(&name, "Folder", "/workspace") resolve_container_dir(&target, "Folder", "/workspace")
.await .await
.expect("/workspace itself"); .expect("/workspace itself");
@@ -3575,7 +3525,7 @@ mod tests {
// reporting itself empty — `-H` follows the start point, and only the // reporting itself empty — `-H` follows the start point, and only the
// start point. // start point.
let (records, diagnostics, code) = let (records, diagnostics, code) =
exec_oneshot_streams_as(&name, "claude", list_argv("/workspace/link"), Vec::new()) target.exec_oneshot_streams_as("claude", list_argv("/workspace/link"), Vec::new())
.await .await
.unwrap(); .unwrap();
println!("link: code={} diagnostics={:?}", code, diagnostics); println!("link: code={} diagnostics={:?}", code, diagnostics);
@@ -3588,7 +3538,7 @@ mod tests {
// The link itself is still *marked* as one in its parent's listing, and // The link itself is still *marked* as one in its parent's listing, and
// still navigable. // still navigable.
let (records, _, _) = let (records, _, _) =
exec_oneshot_streams_as(&name, "claude", list_argv("/workspace"), Vec::new()) target.exec_oneshot_streams_as("claude", list_argv("/workspace"), Vec::new())
.await .await
.unwrap(); .unwrap();
let parent = parse_find_output("/workspace", &records); let parent = parse_find_output("/workspace", &records);
@@ -3607,7 +3557,7 @@ mod tests {
for path in ["/workspace/broken", "/workspace/loop"] { for path in ["/workspace/broken", "/workspace/loop"] {
let started = std::time::Instant::now(); let started = std::time::Instant::now();
let (records, diagnostics, code) = let (records, diagnostics, code) =
exec_oneshot_streams_as(&name, "claude", list_argv(path), Vec::new()) target.exec_oneshot_streams_as("claude", list_argv(path), Vec::new())
.await .await
.unwrap(); .unwrap();
let took = started.elapsed(); let took = started.elapsed();
+61 -82
View File
@@ -12,8 +12,9 @@
//! //!
//! ## Container access //! ## Container access
//! //!
//! All work happens inside the container via the existing `docker exec` //! All work happens inside the container via the existing exec plumbing,
//! plumbing in [`crate::docker::exec`] — no second mechanism is introduced. //! reached through [`crate::host::ContainerTarget`] — no second mechanism is
//! introduced.
//! The heavy lifting (walking dirs, grepping transcripts, parsing JSON with //! The heavy lifting (walking dirs, grepping transcripts, parsing JSON with
//! `jq`) runs *in* the container and only a small JSON summary crosses the //! `jq`) runs *in* the container and only a small JSON summary crosses the
//! wire, so multi-megabyte transcripts are never streamed back. //! wire, so multi-megabyte transcripts are never streamed back.
@@ -56,14 +57,11 @@
//! commands fail loudly, since they cannot do anything useful without a //! commands fail loudly, since they cannot do anything useful without a
//! running container. //! running container.
use bollard::exec::{CreateExecOptions, StartExecOptions};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::State; use tauri::State;
use crate::docker::client::get_docker;
use crate::docker::container::is_container_running;
use crate::docker::exec::{exec_oneshot_env, exec_oneshot_env_status};
use crate::docker::CONTAINER_HOME; use crate::docker::CONTAINER_HOME;
use crate::host::{ContainerTarget, ExecSpec};
use crate::AppState; use crate::AppState;
/// Newest N session transcripts to inspect. Caps the work done inside the /// Newest N session transcripts to inspect. Caps the work done inside the
@@ -192,7 +190,7 @@ pub struct SchedulerNotification {
// Shared helpers // Shared helpers
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/// Resolve a project to a *running* container id. /// Resolve a project to its *running* container.
/// ///
/// `Ok(None)` means "there is nothing to inspect" — no container recorded, or /// `Ok(None)` means "there is nothing to inspect" — no container recorded, or
/// the container exists but is stopped. Callers that are read-only turn that /// the container exists but is stopped. Callers that are read-only turn that
@@ -201,19 +199,19 @@ pub struct SchedulerNotification {
async fn running_container_for( async fn running_container_for(
project_id: &str, project_id: &str,
state: &State<'_, AppState>, state: &State<'_, AppState>,
) -> Result<Option<String>, String> { ) -> Result<Option<ContainerTarget>, String> {
let project = state let project = state
.projects_store .projects_store
.get(project_id) .get(project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?; .ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = match project.container_id { let target = match ContainerTarget::for_project(&project) {
Some(id) => id, Some(t) => t,
None => return Ok(None), None => return Ok(None),
}; };
if is_container_running(&container_id).await.unwrap_or(false) { if target.is_running().await.unwrap_or(false) {
Ok(Some(container_id)) Ok(Some(target))
} else { } else {
Ok(None) Ok(None)
} }
@@ -224,7 +222,7 @@ async fn running_container_for(
async fn require_running_container( async fn require_running_container(
project_id: &str, project_id: &str,
state: &State<'_, AppState>, state: &State<'_, AppState>,
) -> Result<String, String> { ) -> Result<ContainerTarget, String> {
running_container_for(project_id, state).await?.ok_or_else(|| { running_container_for(project_id, state).await?.ok_or_else(|| {
"Container is not running — start the project first.".to_string() "Container is not running — start the project first.".to_string()
}) })
@@ -240,13 +238,13 @@ fn home_env() -> Vec<String> {
/// the first line) so the combined stream `exec_oneshot_env` returns is pure /// the first line) so the combined stream `exec_oneshot_env` returns is pure
/// stdout and stays parseable as JSON. A non-zero exit therefore surfaces as /// stdout and stays parseable as JSON. A non-zero exit therefore surfaces as
/// empty output, which the callers treat as "nothing to report". /// empty output, which the callers treat as "nothing to report".
async fn run_script(container_id: &str, script: impl Into<String>) -> Result<String, String> { async fn run_script(target: &ContainerTarget, script: impl Into<String>) -> Result<String, String> {
exec_oneshot_env( target
container_id, .exec_oneshot_env(
vec!["sh".to_string(), "-c".to_string(), script.into()], vec!["sh".to_string(), "-c".to_string(), script.into()],
home_env(), home_env(),
) )
.await .await
} }
/// Parse script output as JSON, degrading to a default value (and a log line) /// Parse script output as JSON, degrading to a default value (and a log line)
@@ -315,11 +313,11 @@ pub(crate) fn validate_session_id(id: &str) -> Result<(), String> {
/// Run `triple-c-scheduler <args…>` as a bare argv vector — no shell is /// Run `triple-c-scheduler <args…>` as a bare argv vector — no shell is
/// involved, so caller-supplied ids cannot be interpreted as shell syntax. /// involved, so caller-supplied ids cannot be interpreted as shell syntax.
/// Returns the combined output, erroring with it on a non-zero exit. /// Returns the combined output, erroring with it on a non-zero exit.
async fn run_scheduler(container_id: &str, args: Vec<String>) -> Result<String, String> { async fn run_scheduler(target: &ContainerTarget, args: Vec<String>) -> Result<String, String> {
let mut cmd = vec!["triple-c-scheduler".to_string()]; let mut cmd = vec!["triple-c-scheduler".to_string()];
cmd.extend(args); cmd.extend(args);
let (output, exit_code) = exec_oneshot_env_status(container_id, cmd, home_env()).await?; let (output, exit_code) = target.exec_oneshot_env_status(cmd, home_env()).await?;
if exit_code != 0 { if exit_code != 0 {
let detail = output.trim(); let detail = output.trim();
return Err(if detail.is_empty() { return Err(if detail.is_empty() {
@@ -395,8 +393,8 @@ pub async fn list_claude_sessions(
project_id: String, project_id: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Vec<ClaudeSession>, String> { ) -> Result<Vec<ClaudeSession>, String> {
let container_id = match running_container_for(&project_id, &state).await? { let target = match running_container_for(&project_id, &state).await? {
Some(id) => id, Some(t) => t,
None => return Ok(Vec::new()), None => return Ok(Vec::new()),
}; };
@@ -404,7 +402,7 @@ pub async fn list_claude_sessions(
// a const usize — never from the caller. // a const usize — never from the caller.
let script = SESSIONS_SCRIPT.replace("__MAX__", &MAX_SESSIONS.to_string()); let script = SESSIONS_SCRIPT.replace("__MAX__", &MAX_SESSIONS.to_string());
let raw = run_script(&container_id, script).await?; let raw = run_script(&target, script).await?;
let sessions: Vec<RawSession> = parse_or_default(&raw, "session list"); let sessions: Vec<RawSession> = parse_or_default(&raw, "session list");
Ok(sessions Ok(sessions
@@ -566,12 +564,12 @@ pub async fn list_container_capabilities(
project_id: String, project_id: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<ContainerCapabilities, String> { ) -> Result<ContainerCapabilities, String> {
let container_id = match running_container_for(&project_id, &state).await? { let target = match running_container_for(&project_id, &state).await? {
Some(id) => id, Some(t) => t,
None => return Ok(ContainerCapabilities::default()), None => return Ok(ContainerCapabilities::default()),
}; };
let raw = run_script(&container_id, CAPABILITIES_SCRIPT).await?; let raw = run_script(&target, CAPABILITIES_SCRIPT).await?;
Ok(parse_or_default(&raw, "container capabilities")) Ok(parse_or_default(&raw, "container capabilities"))
} }
@@ -681,12 +679,12 @@ pub async fn list_scheduled_tasks(
project_id: String, project_id: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Vec<ScheduledTask>, String> { ) -> Result<Vec<ScheduledTask>, String> {
let container_id = match running_container_for(&project_id, &state).await? { let target = match running_container_for(&project_id, &state).await? {
Some(id) => id, Some(t) => t,
None => return Ok(Vec::new()), None => return Ok(Vec::new()),
}; };
let raw = run_script(&container_id, SCHEDULER_LIST_SCRIPT).await?; let raw = run_script(&target, SCHEDULER_LIST_SCRIPT).await?;
let tasks: Vec<RawScheduledTask> = parse_or_default(&raw, "scheduled tasks"); let tasks: Vec<RawScheduledTask> = parse_or_default(&raw, "scheduled tasks");
Ok(tasks Ok(tasks
@@ -731,13 +729,13 @@ pub async fn get_scheduled_task_log(
// Clamped, and an integer by type — cannot carry shell syntax. // Clamped, and an integer by type — cannot carry shell syntax.
let tail = tail_lines.unwrap_or(200).clamp(1, 5000); let tail = tail_lines.unwrap_or(200).clamp(1, 5000);
let container_id = match running_container_for(&project_id, &state).await? { let target = match running_container_for(&project_id, &state).await? {
Some(id) => id, Some(t) => t,
None => return Ok(String::new()), None => return Ok(String::new()),
}; };
run_scheduler( run_scheduler(
&container_id, &target,
vec![ vec![
"logs".to_string(), "logs".to_string(),
"--id".to_string(), "--id".to_string(),
@@ -756,15 +754,15 @@ pub async fn get_scheduler_notifications(
project_id: String, project_id: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Vec<SchedulerNotification>, String> { ) -> Result<Vec<SchedulerNotification>, String> {
let container_id = match running_container_for(&project_id, &state).await? { let target = match running_container_for(&project_id, &state).await? {
Some(id) => id, Some(t) => t,
None => return Ok(Vec::new()), None => return Ok(Vec::new()),
}; };
let script = let script =
SCHEDULER_NOTIFICATIONS_SCRIPT.replace("__MAX__", &MAX_NOTIFICATIONS.to_string()); SCHEDULER_NOTIFICATIONS_SCRIPT.replace("__MAX__", &MAX_NOTIFICATIONS.to_string());
let raw = run_script(&container_id, script).await?; let raw = run_script(&target, script).await?;
let notifications: Vec<RawNotification> = parse_or_default(&raw, "scheduler notifications"); let notifications: Vec<RawNotification> = parse_or_default(&raw, "scheduler notifications");
Ok(notifications Ok(notifications
@@ -1140,9 +1138,9 @@ pub async fn add_scheduled_task(
&schedule, &schedule,
working_dir.as_deref(), working_dir.as_deref(),
)?; )?;
let container_id = require_running_container(&project_id, &state).await?; let target = require_running_container(&project_id, &state).await?;
let output = run_scheduler(&container_id, input.add_args()).await?; let output = run_scheduler(&target, input.add_args()).await?;
let task_id = parse_created_task_id(&output).ok_or_else(|| { let task_id = parse_created_task_id(&output).ok_or_else(|| {
format!( format!(
"The scheduler did not report a task id. Its output was: {}", "The scheduler did not report a task id. Its output was: {}",
@@ -1200,9 +1198,9 @@ pub async fn update_scheduled_task(
&schedule, &schedule,
working_dir.as_deref(), working_dir.as_deref(),
)?; )?;
let container_id = require_running_container(&project_id, &state).await?; let target = require_running_container(&project_id, &state).await?;
let output = run_scheduler(&container_id, input.add_args()).await?; let output = run_scheduler(&target, input.add_args()).await?;
let new_id = parse_created_task_id(&output).ok_or_else(|| { let new_id = parse_created_task_id(&output).ok_or_else(|| {
format!( format!(
"The scheduler did not report a task id, so the original task was left in place. Its output was: {}", "The scheduler did not report a task id, so the original task was left in place. Its output was: {}",
@@ -1211,7 +1209,7 @@ pub async fn update_scheduled_task(
})?; })?;
run_scheduler( run_scheduler(
&container_id, &target,
vec!["remove".to_string(), "--id".to_string(), task_id.clone()], vec!["remove".to_string(), "--id".to_string(), task_id.clone()],
) )
.await .await
@@ -1224,7 +1222,7 @@ pub async fn update_scheduled_task(
if enabled == Some(false) { if enabled == Some(false) {
if let Err(e) = run_scheduler( if let Err(e) = run_scheduler(
&container_id, &target,
vec!["disable".to_string(), "--id".to_string(), new_id.clone()], vec!["disable".to_string(), "--id".to_string(), new_id.clone()],
) )
.await .await
@@ -1253,11 +1251,11 @@ pub async fn set_scheduled_task_enabled(
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<String, String> { ) -> Result<String, String> {
validate_task_id(&task_id)?; validate_task_id(&task_id)?;
let container_id = require_running_container(&project_id, &state).await?; let target = require_running_container(&project_id, &state).await?;
let subcommand = if enabled { "enable" } else { "disable" }; let subcommand = if enabled { "enable" } else { "disable" };
let output = run_scheduler( let output = run_scheduler(
&container_id, &target,
vec![subcommand.to_string(), "--id".to_string(), task_id], vec![subcommand.to_string(), "--id".to_string(), task_id],
) )
.await?; .await?;
@@ -1277,39 +1275,20 @@ pub async fn run_scheduled_task_now(
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<String, String> { ) -> Result<String, String> {
validate_task_id(&task_id)?; validate_task_id(&task_id)?;
let container_id = require_running_container(&project_id, &state).await?; let target = require_running_container(&project_id, &state).await?;
let docker = get_docker()?; // Argv vector — no shell, so `task_id` is inert as data. Stdin, stdout and
let exec = docker // stderr are all left unattached; the exec is started detached.
.create_exec( let spec = ExecSpec::new(vec![
&container_id, "triple-c-scheduler".to_string(),
CreateExecOptions { "run".to_string(),
attach_stdout: Some(false), "--id".to_string(),
attach_stderr: Some(false), task_id.clone(),
// Argv vector — no shell, so `task_id` is inert as data. ])
cmd: Some(vec![ .env(home_env())
"triple-c-scheduler".to_string(), .no_stdin();
"run".to_string(), let exec_id = target
"--id".to_string(), .exec_detached(spec)
task_id.clone(),
]),
env: Some(home_env()),
user: Some("claude".to_string()),
working_dir: Some("/workspace".to_string()),
..Default::default()
},
)
.await
.map_err(|e| format!("Failed to create exec: {}", e))?;
docker
.start_exec(
&exec.id,
Some(StartExecOptions {
detach: true,
..Default::default()
}),
)
.await .await
.map_err(|e| format!("Failed to start task: {}", e))?; .map_err(|e| format!("Failed to start task: {}", e))?;
@@ -1317,7 +1296,7 @@ pub async fn run_scheduled_task_now(
"Triggered scheduler task {} in project {} (detached exec {})", "Triggered scheduler task {} in project {} (detached exec {})",
task_id, task_id,
project_id, project_id,
exec.id exec_id
); );
Ok(format!("Task {} started.", task_id)) Ok(format!("Task {} started.", task_id))
} }
@@ -1330,10 +1309,10 @@ pub async fn remove_scheduled_task(
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<String, String> { ) -> Result<String, String> {
validate_task_id(&task_id)?; validate_task_id(&task_id)?;
let container_id = require_running_container(&project_id, &state).await?; let target = require_running_container(&project_id, &state).await?;
let output = run_scheduler( let output = run_scheduler(
&container_id, &target,
vec!["remove".to_string(), "--id".to_string(), task_id], vec!["remove".to_string(), "--id".to_string(), task_id],
) )
.await?; .await?;
@@ -1346,9 +1325,9 @@ pub async fn clear_scheduler_notifications(
project_id: String, project_id: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
let container_id = require_running_container(&project_id, &state).await?; let target = require_running_container(&project_id, &state).await?;
run_scheduler( run_scheduler(
&container_id, &target,
vec!["notifications".to_string(), "--clear".to_string()], vec!["notifications".to_string(), "--clear".to_string()],
) )
.await?; .await?;
@@ -7,7 +7,7 @@ use std::collections::{BTreeMap, HashSet};
use tauri::{AppHandle, Emitter, State}; use tauri::{AppHandle, Emitter, State};
use tokio::sync::oneshot; 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::marketplace::{self as mk, auth, diff, gh_login, git, MarketplaceManager};
use crate::models::marketplace::{ use crate::models::marketplace::{
is_valid_commit, is_valid_item_key, AccountMethod, FileDiff, InstallScope, ItemUpdate, 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(); let mut results = Vec::new();
for p in projects { for p in projects {
let running = match &p.container_id { let running = match ContainerTarget::for_project(&p) {
Some(cid) => is_container_running(cid).await.unwrap_or(false), Some(target) => target.is_running().await.unwrap_or(false),
None => false, None => false,
}; };
if !running { 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()); return Err("Signing in through a container needs a host name without a port.".to_string());
} }
let project = find_project(&state, &project_id)?; 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!( format!(
"\"{}\" has no container yet. Start it, then try again.", "\"{}\" has no container yet. Start it, then try again.",
project.name project.name
) )
})?; })?;
if !is_container_running(&container_id).await.unwrap_or(false) { if !target.is_running().await.unwrap_or(false) {
return Err(format!( return Err(format!(
"\"{}\" is not running. Start it, then try again.", "\"{}\" is not running. Start it, then try again.",
project.name project.name
@@ -1070,7 +1070,7 @@ pub async fn start_marketplace_gh_container_login(
} }
let account_id = uuid::Uuid::new_v4().to_string(); let account_id = uuid::Uuid::new_v4().to_string();
let result = 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. // `rx` is gone now, so this frees our slot and never a newer login's.
state.marketplace.release_gh_login().await; state.marketplace.release_gh_login().await;
let token = result?; let token = result?;
@@ -1860,7 +1860,7 @@ async fn stage_payload(
project_id: &str, project_id: &str,
verbatim: &[String], verbatim: &[String],
) -> Result<String, String> { ) -> Result<String, String> {
use bollard::container::LogOutput; use crate::host::ExecChunk;
use futures_util::StreamExt; use futures_util::StreamExt;
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
@@ -1913,14 +1913,14 @@ async fn stage_payload(
while let Some(msg) = output.next().await { while let Some(msg) = output.next().await {
match msg { match msg {
Ok(LogOutput::StdOut { message }) => { Ok(ExecChunk::StdOut(message)) => {
if let Err(e) = writer.write_all(&message).await { if let Err(e) = writer.write_all(&message).await {
stream_err = Some(format!("Failed to write the staging file: {}", e)); stream_err = Some(format!("Failed to write the staging file: {}", e));
break; break;
} }
total += message.len() as u64; total += message.len() as u64;
} }
Ok(LogOutput::StdErr { message }) => { Ok(ExecChunk::StdErr(message)) => {
stderr_text.push_str(&String::from_utf8_lossy(&message)); stderr_text.push_str(&String::from_utf8_lossy(&message));
} }
Ok(_) => {} Ok(_) => {}
@@ -1979,7 +1979,7 @@ async fn stage_payload(
/// anything already present in the base, so collisions should be rare either /// anything already present in the base, so collisions should be rare either
/// way.) /// way.)
async fn restore_payload(container_id: &str, host_path: &str) -> Result<(), String> { async fn restore_payload(container_id: &str, host_path: &str) -> Result<(), String> {
use bollard::container::LogOutput; use crate::host::ExecChunk;
use futures_util::StreamExt; use futures_util::StreamExt;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -2007,7 +2007,7 @@ async fn restore_payload(container_id: &str, host_path: &str) -> Result<(), Stri
let mut text = String::new(); let mut text = String::new();
while let Some(msg) = output.next().await { while let Some(msg) = output.next().await {
match msg { match msg {
Ok(LogOutput::StdErr { message }) | Ok(LogOutput::StdOut { message }) => { Ok(ExecChunk::StdErr(message)) | Ok(ExecChunk::StdOut(message)) => {
text.push_str(&String::from_utf8_lossy(&message)) text.push_str(&String::from_utf8_lossy(&message))
} }
Ok(_) => {} Ok(_) => {}
+20 -23
View File
@@ -1,6 +1,7 @@
use tauri::{AppHandle, Emitter, State}; use tauri::{AppHandle, Emitter, State};
use crate::commands::aws_commands; use crate::commands::aws_commands;
use crate::host::ContainerTarget;
use crate::models::{Backend, BedrockAuthMethod, Project}; use crate::models::{Backend, BedrockAuthMethod, Project};
use crate::AppState; use crate::AppState;
@@ -159,9 +160,7 @@ pub async fn open_terminal_session(
.get(&project_id) .get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?; .ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project let target = ContainerTarget::for_project(&project)
.container_id
.as_ref()
.ok_or_else(|| "Container not running".to_string())?; .ok_or_else(|| "Container not running".to_string())?;
if let Some(id) = resume_session_id.as_deref() { if let Some(id) = resume_session_id.as_deref() {
@@ -189,7 +188,7 @@ pub async fn open_terminal_session(
state state
.exec_manager .exec_manager
.create_session( .create_session(
container_id, &target,
&session_id, &session_id,
cmd, cmd,
move |data| { move |data| {
@@ -240,7 +239,7 @@ pub async fn paste_image_to_terminal(
image_data: Vec<u8>, image_data: Vec<u8>,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<String, String> { ) -> Result<String, String> {
let container_id = state.exec_manager.get_container_id(&session_id).await?; let target = state.exec_manager.get_target(&session_id).await?;
let timestamp = std::time::SystemTime::now() let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
@@ -250,7 +249,7 @@ pub async fn paste_image_to_terminal(
state state
.exec_manager .exec_manager
.write_file_to_container(&container_id, &file_name, &image_data) .write_file_to_container(&target, &file_name, &image_data)
.await .await
} }
@@ -283,7 +282,7 @@ pub async fn upload_host_file_to_terminal(
let base = crate::commands::file_commands::host_upload_name(&host_path)?; 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 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 = state.exec_manager.get_target(&session_id).await?;
let meta = tokio::fs::metadata(&host_path) let meta = tokio::fs::metadata(&host_path)
.await .await
@@ -313,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 // 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 // descriptor — this check is here only so the refusal reads like a sentence
// instead of arriving after a 300 MB read. // 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 { if meta.len() > MAX_DROP_BYTES {
return Err(format!( return Err(format!(
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project instead.", "File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project instead.",
@@ -326,20 +325,18 @@ pub async fn upload_host_file_to_terminal(
// Ensure the destination directory exists rather than relying on Docker's // Ensure the destination directory exists rather than relying on Docker's
// archive extractor to create the parent for the uploaded tar entry. // archive extractor to create the parent for the uploaded tar entry.
crate::docker::exec::exec_oneshot( target
&container_id, .exec_oneshot(vec![
vec!["mkdir".to_string(), "-p".to_string(), "/tmp/triple-c-drops".to_string()], "mkdir".to_string(),
) "-p".to_string(),
.await?; "/tmp/triple-c-drops".to_string(),
])
.await?;
let file_name = format!("triple-c-drops/{}", base); let file_name = format!("triple-c-drops/{}", base);
crate::docker::exec::upload_host_file_to_container( target
&container_id, .upload_host_file(&host_path, "/tmp", &file_name)
&host_path, .await
"/tmp",
&file_name,
)
.await
} }
#[tauri::command] #[tauri::command]
@@ -347,8 +344,8 @@ pub async fn start_audio_bridge(
session_id: String, session_id: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
// Get container_id from the terminal session // Get the container from the terminal session
let container_id = state.exec_manager.get_container_id(&session_id).await?; let target = state.exec_manager.get_target(&session_id).await?;
// Create audio bridge exec session with ID "audio-{session_id}" // Create audio bridge exec session with ID "audio-{session_id}"
// The loop handles reconnection when the FIFO reader (fake rec) is killed and restarted // The loop handles reconnection when the FIFO reader (fake rec) is killed and restarted
@@ -362,7 +359,7 @@ pub async fn start_audio_bridge(
state state
.exec_manager .exec_manager
.create_session_with_tty( .create_session_with_tty(
&container_id, &target,
&audio_session_id, &audio_session_id,
cmd, cmd,
false, false,
+115 -277
View File
@@ -1,58 +1,44 @@
use bollard::container::{LogOutput, UploadToContainerOptions}; use bollard::container::{LogOutput, UploadToContainerOptions};
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults}; use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
use futures_util::{Stream, StreamExt}; use futures_util::StreamExt;
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use tokio::io::{AsyncWrite, AsyncWriteExt};
use tokio::sync::{mpsc, Mutex};
use super::client::get_docker; use super::client::get_docker;
use crate::host::{ExecChunk, ExecSpec};
/// A `docker exec` that has been created and started with stdin/stdout/stderr // `AttachedExec` lives in `host` now (host-neutral output chunks); this
/// attached — the raw duplex halves, before any policy about what to do with // module's job is to produce one from Docker. Its consumers are
/// them. // `host::sessions::ExecSessionManager` (terminals, audio bridge) and the auth
/// // bridge's per-connection `socat` tunnels, both through `host::ContainerTarget`.
/// This is the single place in the codebase that knows how to open an attached // With `tty = false` Docker demultiplexes stdout from stderr, which the auth
/// exec. Both consumers are built on it: // bridge relies on: `socat`'s diagnostics must not be spliced into the proxied
/// * [`ExecSessionManager`] — interactive terminals and the audio bridge, // byte stream.
/// which pump bytes through mpsc channels and a callback. pub use crate::host::AttachedExec;
/// * `auth_bridge` — per-connection `socat` tunnels, which pump bytes
/// straight between a host TCP socket and these halves.
///
/// With `tty = false` the output stream is demultiplexed by Docker, so the
/// consumer can tell [`LogOutput::StdOut`] from [`LogOutput::StdErr`]. That
/// distinction matters for the auth bridge: `socat`'s diagnostics must not be
/// spliced into the proxied byte stream.
pub struct AttachedExec {
pub exec_id: String,
pub output: Pin<Box<dyn Stream<Item = Result<LogOutput, bollard::errors::Error>> + Send>>,
pub input: Pin<Box<dyn AsyncWrite + Send>>,
}
/// Create and start an exec with stdin + stdout + stderr attached, returning the /// An attached exec with the user and working directory spelled out.
/// raw duplex halves. Runs as `claude` in `/workspace`, like every other exec
/// this app opens.
pub async fn create_attached_exec(
container_id: &str,
cmd: Vec<String>,
tty: bool,
) -> Result<AttachedExec, String> {
create_attached_exec_as(container_id, cmd, tty, "claude", "/workspace").await
}
/// [`create_attached_exec`] with the user and working directory spelled out.
/// ///
/// Only base-image migration needs this: replaying `apt` and unpacking a /// Only base-image migration calls this directly: replaying `apt` and unpacking
/// payload tar at `/` have to run as **root**, and every other caller wants the /// a payload tar at `/` have to run as **root**. Everything else goes through
/// `claude` / `/workspace` defaults that [`create_attached_exec`] supplies. It /// `host::ContainerTarget::attach`.
/// stays the single place an attached exec is opened.
pub async fn create_attached_exec_as( pub async fn create_attached_exec_as(
container_id: &str, container_id: &str,
cmd: Vec<String>, cmd: Vec<String>,
tty: bool, tty: bool,
user: &str, user: &str,
working_dir: &str, working_dir: &str,
) -> Result<AttachedExec, String> {
let spec = ExecSpec::new(cmd)
.tty(tty)
.user(user)
.working_dir(Some(working_dir));
create_attached_exec_spec(container_id, &spec).await
}
/// The single place an attached exec is opened on Docker. Everything else —
/// [`create_attached_exec_as`] and `host::ContainerTarget::attach` — is a thin
/// wrapper over this.
pub async fn create_attached_exec_spec(
container_id: &str,
spec: &ExecSpec,
) -> Result<AttachedExec, String> { ) -> Result<AttachedExec, String> {
let docker = get_docker()?; let docker = get_docker()?;
@@ -60,13 +46,14 @@ pub async fn create_attached_exec_as(
.create_exec( .create_exec(
container_id, container_id,
CreateExecOptions { CreateExecOptions {
attach_stdin: Some(true), attach_stdin: Some(spec.attach_stdin),
attach_stdout: Some(true), attach_stdout: Some(true),
attach_stderr: Some(true), attach_stderr: Some(true),
tty: Some(tty), tty: Some(spec.tty),
cmd: Some(cmd), cmd: Some(spec.cmd.clone()),
user: Some(user.to_string()), env: if spec.env.is_empty() { None } else { Some(spec.env.clone()) },
working_dir: Some(working_dir.to_string()), user: Some(spec.user.clone()),
working_dir: spec.working_dir.clone(),
..Default::default() ..Default::default()
}, },
) )
@@ -82,244 +69,87 @@ pub async fn create_attached_exec_as(
{ {
StartExecResults::Attached { output, input } => Ok(AttachedExec { StartExecResults::Attached { output, input } => Ok(AttachedExec {
exec_id, exec_id,
output, output: Box::pin(output.map(|r| {
r.map(log_output_to_chunk)
.map_err(|e| e.to_string())
})),
input, input,
}), }),
StartExecResults::Detached => Err("Exec started in detached mode".to_string()), StartExecResults::Detached => Err("Exec started in detached mode".to_string()),
} }
} }
pub struct ExecSession { /// bollard's `LogOutput` → the host-neutral [`ExecChunk`]. `Console` is what
pub exec_id: String, /// Docker calls a TTY stream; it has no stderr, so it is stdout.
pub container_id: String, pub fn log_output_to_chunk(o: LogOutput) -> ExecChunk {
pub input_tx: mpsc::UnboundedSender<Vec<u8>>, match o {
shutdown_tx: mpsc::Sender<()>, LogOutput::StdOut { message } | LogOutput::Console { message } => ExecChunk::StdOut(message),
} LogOutput::StdErr { message } => ExecChunk::StdErr(message),
LogOutput::StdIn { message } => ExecChunk::Other(message),
impl ExecSession {
pub async fn send_input(&self, data: Vec<u8>) -> Result<(), String> {
self.input_tx
.send(data)
.map_err(|e| format!("Failed to send input: {}", e))
}
#[allow(dead_code)]
pub async fn resize(&self, cols: u16, rows: u16) -> Result<(), String> {
let docker = get_docker()?;
docker
.resize_exec(
&self.exec_id,
ResizeExecOptions {
width: cols,
height: rows,
},
)
.await
.map_err(|e| format!("Failed to resize exec: {}", e))
}
pub fn shutdown(&self) {
let _ = self.shutdown_tx.try_send(());
} }
} }
pub struct ExecSessionManager { /// Start a command with nothing attached and return its exec id without
sessions: Arc<Mutex<HashMap<String, ExecSession>>>, /// waiting for it.
pub async fn exec_detached(container_id: &str, spec: &ExecSpec) -> Result<String, String> {
let docker = get_docker()?;
let exec = docker
.create_exec(
container_id,
CreateExecOptions {
attach_stdout: Some(false),
attach_stderr: Some(false),
tty: Some(spec.tty),
cmd: Some(spec.cmd.clone()),
env: if spec.env.is_empty() { None } else { Some(spec.env.clone()) },
user: Some(spec.user.clone()),
working_dir: spec.working_dir.clone(),
..Default::default()
},
)
.await
.map_err(|e| format!("Failed to create exec: {}", e))?;
docker
.start_exec(
&exec.id,
Some(bollard::exec::StartExecOptions {
detach: true,
..Default::default()
}),
)
.await
.map_err(|e| format!("Failed to start exec: {}", e))?;
Ok(exec.id)
} }
impl ExecSessionManager { pub async fn resize_exec(exec_id: &str, cols: u16, rows: u16) -> Result<(), String> {
pub fn new() -> Self { let docker = get_docker()?;
Self { docker
sessions: Arc::new(Mutex::new(HashMap::new())), .resize_exec(
}
}
pub async fn create_session<F>(
&self,
container_id: &str,
session_id: &str,
cmd: Vec<String>,
on_output: F,
on_exit: Box<dyn FnOnce() + Send>,
) -> Result<(), String>
where
F: Fn(Vec<u8>) + Send + 'static,
{
self.create_session_with_tty(container_id, session_id, cmd, true, on_output, on_exit)
.await
}
pub async fn create_session_with_tty<F>(
&self,
container_id: &str,
session_id: &str,
cmd: Vec<String>,
tty: bool,
on_output: F,
on_exit: Box<dyn FnOnce() + Send>,
) -> Result<(), String>
where
F: Fn(Vec<u8>) + Send + 'static,
{
let AttachedExec {
exec_id, exec_id,
mut output, ResizeExecOptions {
mut input, width: cols,
} = create_attached_exec(container_id, cmd, tty).await?; height: rows,
},
)
.await
.map_err(|e| format!("Failed to resize exec: {}", e))
}
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>(); /// Docker's archive API: `path` as a tar stream. Errors are stringified so the
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1); /// stream is host-neutral.
pub fn download_archive(container_id: &str, path: &str) -> crate::host::ArchiveStream {
// Output reader task let docker = match get_docker() {
let session_id_clone = session_id.to_string(); Ok(d) => d,
let shutdown_tx_clone = shutdown_tx.clone(); Err(e) => return Box::pin(futures_util::stream::once(async move { Err(e) })),
tokio::spawn(async move { };
loop { let stream = docker.download_from_container(
tokio::select! { container_id,
msg = output.next() => { Some(bollard::container::DownloadFromContainerOptions {
match msg { path: path.to_string(),
Some(Ok(output)) => { }),
on_output(output.into_bytes().to_vec()); );
} Box::pin(stream.map(|r| r.map_err(|e| e.to_string())))
Some(Err(e)) => {
log::error!("Exec output error for {}: {}", session_id_clone, e);
break;
}
None => {
log::info!("Exec output stream ended for {}", session_id_clone);
break;
}
}
}
_ = shutdown_rx.recv() => {
log::info!("Exec session {} shutting down", session_id_clone);
break;
}
}
}
on_exit();
let _ = shutdown_tx_clone;
});
// Input writer task
tokio::spawn(async move {
while let Some(data) = input_rx.recv().await {
if let Err(e) = input.write_all(&data).await {
log::error!("Failed to write to exec stdin: {}", e);
break;
}
}
});
let session = ExecSession {
exec_id,
container_id: container_id.to_string(),
input_tx,
shutdown_tx,
};
self.sessions
.lock()
.await
.insert(session_id.to_string(), session);
Ok(())
}
pub async fn send_input(&self, session_id: &str, data: Vec<u8>) -> Result<(), String> {
let sessions = self.sessions.lock().await;
let session = sessions
.get(session_id)
.ok_or_else(|| format!("Session {} not found", session_id))?;
session.send_input(data).await
}
pub async fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
// Clone the exec_id under the lock, then drop the lock before the
// async Docker API call to avoid holding the mutex across await.
let exec_id = {
let sessions = self.sessions.lock().await;
let session = sessions
.get(session_id)
.ok_or_else(|| format!("Session {} not found", session_id))?;
session.exec_id.clone()
};
let docker = get_docker()?;
docker
.resize_exec(
&exec_id,
ResizeExecOptions {
width: cols,
height: rows,
},
)
.await
.map_err(|e| format!("Failed to resize exec: {}", e))
}
pub async fn close_session(&self, session_id: &str) {
let mut sessions = self.sessions.lock().await;
if let Some(session) = sessions.remove(session_id) {
session.shutdown();
}
}
pub async fn close_sessions_for_container(&self, container_id: &str) {
let mut sessions = self.sessions.lock().await;
let ids_to_close: Vec<String> = sessions
.iter()
.filter(|(_, s)| s.container_id == container_id)
.map(|(id, _)| id.clone())
.collect();
for id in ids_to_close {
if let Some(session) = sessions.remove(&id) {
session.shutdown();
}
}
}
pub async fn close_all_sessions(&self) {
let mut sessions = self.sessions.lock().await;
for (_, session) in sessions.drain() {
session.shutdown();
}
}
pub async fn get_container_id(&self, session_id: &str) -> Result<String, String> {
let sessions = self.sessions.lock().await;
let session = sessions
.get(session_id)
.ok_or_else(|| format!("Session {} not found", session_id))?;
Ok(session.container_id.clone())
}
pub async fn write_file_to_container(
&self,
container_id: &str,
file_name: &str,
data: &[u8],
) -> Result<String, String> {
let docker = get_docker()?;
// Owned by the container user, stamped now: a default tar header would
// land it as root:root/1970 and Claude Code could not rewrite it.
let (uid, gid) = container_user_ids(container_id).await;
let tar_buf = build_single_file_tar(file_name, data, 0o644, uid, gid, now_epoch_secs())?;
docker
.upload_to_container(
container_id,
Some(UploadToContainerOptions {
path: "/tmp".to_string(),
..Default::default()
}),
tar_buf.into(),
)
.await
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
Ok(format!("/tmp/{}", file_name))
}
} }
/// Ceiling on one host file packed into a container upload. /// Ceiling on one host file packed into a container upload.
@@ -444,14 +274,22 @@ pub async fn upload_bytes_to_container(
file_name: &str, file_name: &str,
data: &[u8], data: &[u8],
mode: u32, mode: u32,
) -> Result<String, String> {
upload_bytes_with_ids(container_id, dest_dir, file_name, data, mode, (0, 0)).await
}
/// [`upload_bytes_to_container`] with the tar header's owner set, stamped now.
pub async fn upload_bytes_with_ids(
container_id: &str,
dest_dir: &str,
file_name: &str,
data: &[u8],
mode: u32,
(uid, gid): (u64, u64),
) -> Result<String, String> { ) -> Result<String, String> {
let docker = get_docker()?; let docker = get_docker()?;
// Root-owned on purpose: migration's `tar -T` list is read back as root, let tar_buf = build_single_file_tar(file_name, data, mode, uid, gid, now_epoch_secs())?;
// and the marketplace sync uploads into a `claude`-owned directory it
// prepares first, so `claude` can still read and delete the files. The
// mtime still gets stamped so the file doesn't read as 1970.
let tar_buf = build_single_file_tar(file_name, data, mode, 0, 0, now_epoch_secs())?;
docker docker
.upload_to_container( .upload_to_container(
+4 -3
View File
@@ -7,7 +7,7 @@
use serde::Serialize; use serde::Serialize;
use crate::docker::exec::exec_oneshot_streams_as; use crate::host::ContainerTarget;
#[derive(Clone, Debug, Serialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct ViewerPoll { pub struct ViewerPoll {
@@ -48,8 +48,9 @@ pub async fn poll_file(container_id: &str, container_path: &str) -> Result<Viewe
"poll".to_string(), "poll".to_string(),
container_path.to_string(), container_path.to_string(),
]; ];
let (stdout, stderr, code) = let (stdout, stderr, code) = ContainerTarget::docker(container_id)
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?; .exec_oneshot_streams_as("claude", cmd, Vec::new())
.await?;
if code != 0 && code != 4 { if code != 0 && code != 4 {
return Err(format!( return Err(format!(
"Could not check the file: {}", "Could not check the file: {}",
+4 -3
View File
@@ -10,8 +10,8 @@
//! hold the resolved path, not the one that was clicked. //! hold the resolved path, not the one that was clicked.
use crate::commands::file_commands::validate_container_path; use crate::commands::file_commands::validate_container_path;
use crate::docker::exec::exec_oneshot_streams_as;
use crate::docker::CONTAINER_HOME; use crate::docker::CONTAINER_HOME;
use crate::host::ContainerTarget;
pub const MAX_CANDIDATES: usize = 16; pub const MAX_CANDIDATES: usize = 16;
const MAX_RAW_LEN: usize = 4096; const MAX_RAW_LEN: usize = 4096;
@@ -136,8 +136,9 @@ pub async fn probe_candidates(
) -> Result<Vec<String>, String> { ) -> Result<Vec<String>, String> {
let mut cmd: Vec<String> = vec!["sh".into(), "-c".into(), PROBE_SCRIPT.into(), "probe".into()]; let mut cmd: Vec<String> = vec!["sh".into(), "-c".into(), PROBE_SCRIPT.into(), "probe".into()];
cmd.extend(candidates.iter().cloned()); cmd.extend(candidates.iter().cloned());
let (stdout, _stderr, _code) = let (stdout, _stderr, _code) = ContainerTarget::docker(container_id)
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?; .exec_oneshot_streams_as("claude", cmd, Vec::new())
.await?;
Ok(parse_probe_output(&stdout)) Ok(parse_probe_output(&stdout))
} }
+6 -4
View File
@@ -11,7 +11,7 @@ use serde::Serialize;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use crate::commands::file_commands::clip_container_text; use crate::commands::file_commands::clip_container_text;
use crate::docker::exec::{exec_oneshot_streams_as, ExecSessionManager}; use crate::host::{ContainerTarget, ExecSessionManager};
/// Spec §4/§5: only untruncated (≤ 1 MiB) text is editable, so nothing larger is saved. /// Spec §4/§5: only untruncated (≤ 1 MiB) text is editable, so nothing larger is saved.
pub const MAX_WRITE_BYTES: usize = 1024 * 1024; pub const MAX_WRITE_BYTES: usize = 1024 * 1024;
@@ -180,12 +180,14 @@ pub async fn write_file(
) -> Result<SavedFile, String> { ) -> Result<SavedFile, String> {
check_write_input(bytes.len(), base_hash)?; check_write_input(bytes.len(), base_hash)?;
let tmp_name = format!("triple-c-viewer-{}", uuid::Uuid::new_v4().simple()); let tmp_name = format!("triple-c-viewer-{}", uuid::Uuid::new_v4().simple());
let container = ContainerTarget::docker(container_id);
let tmp_path = exec_manager let tmp_path = exec_manager
.write_file_to_container(container_id, &tmp_name, bytes) .write_file_to_container(&container, &tmp_name, bytes)
.await?; .await?;
let cmd = write_command(target, &tmp_path, base_hash); let cmd = write_command(target, &tmp_path, base_hash);
let (stdout, stderr, code) = let (stdout, stderr, code) = container
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?; .exec_oneshot_streams_as("claude", cmd, Vec::new())
.await?;
saved_file(classify_write(code, &stdout, &stderr), bytes) saved_file(classify_write(code, &stdout, &stderr), bytes)
} }
+474
View File
@@ -0,0 +1,474 @@
//! Where a project's container runs, and the one interface the rest of the app
//! uses to reach inside it.
//!
//! Spec: `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md` ("Host
//! abstraction"). This is phase 1 of that plan: every operation that happens
//! *inside* a project container — one-shot and attached execs, file transfer,
//! "is it running" — goes through [`ContainerTarget`] instead of naming
//! `docker::exec` or bollard directly. Today the only variant is
//! [`ContainerTarget::Docker`], which delegates to the existing code in
//! `docker/exec.rs` and `docker/container.rs` with no change in behaviour. An
//! AWS variant (reached through the in-container agent) slots in beside it.
//!
//! ## Why an enum and not a trait object
//!
//! The set of hosts is closed and known at compile time, every method is async,
//! and a `dyn` async trait would need boxing on every call (or the
//! `async-trait` crate) for no benefit. Adding a host means adding a variant and
//! letting the compiler point at every `match` that needs an arm.
//!
//! ## What does *not* go through here
//!
//! Things that only make sense for the local Docker host: image build/pull,
//! base-image migration, snapshot commits, volumes, the LiteLLM gateway and STT
//! sidecars, and (until phase 1b) container create/start/stop and the `die`
//! event stream. Those keep calling `crate::docker` directly. The
//! `no_container_interior_bollard_outside_the_host_layer` test pins which files
//! may still do so.
pub mod sessions;
pub use crate::docker::exec::{
MAX_DROP_BYTES, OUTPUT_LIMIT_MARKER, PROC_NET_OUTPUT_LIMIT,
};
pub use sessions::ExecSessionManager;
use bytes::Bytes;
use futures_util::Stream;
use std::pin::Pin;
use tokio::io::AsyncWrite;
use crate::docker;
use crate::models::Project;
/// One chunk of output from an exec, already demultiplexed.
///
/// Host-neutral stand-in for bollard's `LogOutput`. With `tty = true` there is
/// no stdout/stderr split and everything arrives as [`ExecChunk::StdOut`] (Docker
/// reports a TTY stream as `Console`, which maps here too — consumers that cared
/// treated the two the same).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExecChunk {
StdOut(Bytes),
StdErr(Bytes),
/// stdin echo or anything else a host might surface. Consumers ignore it.
Other(Bytes),
}
impl ExecChunk {
pub fn into_bytes(self) -> Bytes {
match self {
ExecChunk::StdOut(b) | ExecChunk::StdErr(b) | ExecChunk::Other(b) => b,
}
}
}
pub type ExecOutputStream = Pin<Box<dyn Stream<Item = Result<ExecChunk, String>> + Send>>;
/// An exec that has been created and started with its streams attached.
///
/// `input` is a sink that is never read when the exec was opened with
/// `attach_stdin: false`.
pub struct AttachedExec {
pub exec_id: String,
pub output: ExecOutputStream,
pub input: Pin<Box<dyn AsyncWrite + Send>>,
}
/// Everything about an attached exec except where it runs.
///
/// Defaults match what this app has always used: run as `claude` in
/// `/workspace`, no TTY, stdin attached, no extra env.
#[derive(Debug, Clone)]
pub struct ExecSpec {
pub cmd: Vec<String>,
pub user: String,
pub working_dir: Option<String>,
pub env: Vec<String>,
pub tty: bool,
pub attach_stdin: bool,
}
impl ExecSpec {
pub fn new(cmd: Vec<String>) -> Self {
Self {
cmd,
user: "claude".to_string(),
working_dir: Some("/workspace".to_string()),
env: Vec::new(),
tty: false,
attach_stdin: true,
}
}
pub fn user(mut self, user: &str) -> Self {
self.user = user.to_string();
self
}
pub fn working_dir(mut self, dir: Option<&str>) -> Self {
self.working_dir = dir.map(str::to_string);
self
}
pub fn env(mut self, env: Vec<String>) -> Self {
self.env = env;
self
}
pub fn tty(mut self, tty: bool) -> Self {
self.tty = tty;
self
}
pub fn no_stdin(mut self) -> Self {
self.attach_stdin = false;
self
}
}
/// A byte stream of a tar archive read out of a container.
pub type ArchiveStream = Pin<Box<dyn Stream<Item = Result<Bytes, String>> + Send>>;
/// A specific project container on a specific host.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ContainerTarget {
/// A container on the local Docker daemon, by id or name.
Docker { container_id: String },
}
impl ContainerTarget {
pub fn docker(container_id: impl Into<String>) -> Self {
ContainerTarget::Docker {
container_id: container_id.into(),
}
}
/// The target for a project's container, if it has one yet.
pub fn for_project(project: &Project) -> Option<Self> {
project.container_id.as_deref().map(Self::docker)
}
/// The host-specific identifier (a Docker container id today). For logs
/// and for keying per-container state; never parse it.
pub fn id(&self) -> &str {
match self {
ContainerTarget::Docker { container_id } => container_id,
}
}
// ── state ────────────────────────────────────────────────────────────────
/// Whether the container is running. An inspect failure (gone, daemon
/// down) reads as `false`, as `docker::is_container_running` always has.
pub async fn is_running(&self) -> Result<bool, String> {
match self {
ContainerTarget::Docker { container_id } => {
docker::container::is_container_running(container_id).await
}
}
}
// ── one-shot execs ───────────────────────────────────────────────────────
/// Run as `claude`, return combined output. Errors on a non-UTF-8-safe
/// transport failure only; a non-zero exit is *not* an error here.
pub async fn exec_oneshot(&self, cmd: Vec<String>) -> Result<String, String> {
match self {
ContainerTarget::Docker { container_id } => {
docker::exec::exec_oneshot(container_id, cmd).await
}
}
}
pub async fn exec_oneshot_env(
&self,
cmd: Vec<String>,
env: Vec<String>,
) -> Result<String, String> {
match self {
ContainerTarget::Docker { container_id } => {
docker::exec::exec_oneshot_env(container_id, cmd, env).await
}
}
}
pub async fn exec_oneshot_env_status(
&self,
cmd: Vec<String>,
env: Vec<String>,
) -> Result<(String, i64), String> {
match self {
ContainerTarget::Docker { container_id } => {
docker::exec::exec_oneshot_env_status(container_id, cmd, env).await
}
}
}
pub async fn exec_oneshot_as(
&self,
user: &str,
cmd: Vec<String>,
env: Vec<String>,
) -> Result<(String, i64), String> {
match self {
ContainerTarget::Docker { container_id } => {
docker::exec::exec_oneshot_as(container_id, user, cmd, env).await
}
}
}
pub async fn exec_oneshot_limited(
&self,
user: &str,
cmd: Vec<String>,
limit: usize,
) -> Result<(String, i64), String> {
match self {
ContainerTarget::Docker { container_id } => {
docker::exec::exec_oneshot_limited(container_id, user, cmd, limit).await
}
}
}
/// stdout and stderr kept apart: `(stdout, stderr, exit_code)`.
pub async fn exec_oneshot_streams_as(
&self,
user: &str,
cmd: Vec<String>,
env: Vec<String>,
) -> Result<(String, String, i64), String> {
match self {
ContainerTarget::Docker { container_id } => {
docker::exec::exec_oneshot_streams_as(container_id, user, cmd, env).await
}
}
}
/// Start a command and return without waiting or reading its output.
/// Returns the exec id.
pub async fn exec_detached(&self, spec: ExecSpec) -> Result<String, String> {
match self {
ContainerTarget::Docker { container_id } => {
docker::exec::exec_detached(container_id, &spec).await
}
}
}
// ── attached execs ───────────────────────────────────────────────────────
/// Run as `claude` in `/workspace` with stdin attached.
pub async fn attach_exec(&self, cmd: Vec<String>, tty: bool) -> Result<AttachedExec, String> {
self.attach(ExecSpec::new(cmd).tty(tty)).await
}
pub async fn attach(&self, spec: ExecSpec) -> Result<AttachedExec, String> {
match self {
ContainerTarget::Docker { container_id } => {
docker::exec::create_attached_exec_spec(container_id, &spec).await
}
}
}
/// The exit code of an exec this target started, once it has finished.
/// `None` if the host could not say.
pub async fn wait_exec_exit(&self, exec_id: &str) -> Option<i64> {
match self {
ContainerTarget::Docker { .. } => docker::exec::wait_for_exec_exit(exec_id).await,
}
}
pub async fn resize_exec(&self, exec_id: &str, cols: u16, rows: u16) -> Result<(), String> {
match self {
ContainerTarget::Docker { .. } => docker::exec::resize_exec(exec_id, cols, rows).await,
}
}
// ── files ────────────────────────────────────────────────────────────────
/// The container user's `(uid, gid)`, falling back to `(1000, 1000)`.
pub async fn user_ids(&self) -> (u64, u64) {
match self {
ContainerTarget::Docker { container_id } => {
docker::exec::container_user_ids(container_id).await
}
}
}
/// Write `data` to `dest_dir/file_name` as root-owned with `mode`.
pub async fn upload_bytes(
&self,
dest_dir: &str,
file_name: &str,
data: &[u8],
mode: u32,
) -> Result<String, String> {
match self {
ContainerTarget::Docker { container_id } => {
docker::exec::upload_bytes_to_container(
container_id,
dest_dir,
file_name,
data,
mode,
)
.await
}
}
}
/// [`Self::upload_bytes`] with the owner set to `ids`.
pub async fn upload_bytes_with_ids(
&self,
dest_dir: &str,
file_name: &str,
data: &[u8],
mode: u32,
ids: (u64, u64),
) -> Result<String, String> {
match self {
ContainerTarget::Docker { container_id } => {
docker::exec::upload_bytes_with_ids(
container_id,
dest_dir,
file_name,
data,
mode,
ids,
)
.await
}
}
}
/// Copy a host file in, owned by the container user.
pub async fn upload_host_file(
&self,
host_path: &str,
dest_dir: &str,
dest_name: &str,
) -> Result<String, String> {
match self {
ContainerTarget::Docker { container_id } => {
docker::exec::upload_host_file_to_container(
container_id,
host_path,
dest_dir,
dest_name,
)
.await
}
}
}
/// [`Self::upload_host_file`] with the owner ids already known.
pub async fn upload_host_file_with_ids(
&self,
host_path: &str,
dest_dir: &str,
dest_name: &str,
ids: (u64, u64),
) -> Result<String, String> {
match self {
ContainerTarget::Docker { container_id } => {
docker::exec::upload_host_file_with_ids(
container_id,
host_path,
dest_dir,
dest_name,
ids,
)
.await
}
}
}
/// Read `path` out of the container as a tar stream (Docker's archive
/// API). Dropping the stream cancels the transfer.
pub fn download_archive(&self, path: &str) -> ArchiveStream {
match self {
ContainerTarget::Docker { container_id } => {
docker::exec::download_archive(container_id, path)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exec_spec_defaults_match_what_the_app_always_used() {
let s = ExecSpec::new(vec!["true".into()]);
assert_eq!(s.user, "claude");
assert_eq!(s.working_dir.as_deref(), Some("/workspace"));
assert!(s.env.is_empty());
assert!(!s.tty);
assert!(s.attach_stdin);
}
#[test]
fn for_project_follows_the_container_id() {
let mut p: Project = serde_json::from_value(serde_json::json!({
"id": "p1", "name": "p", "paths": [], "container_id": null,
"status": "stopped", "backend": "anthropic",
"allow_docker_access": false, "full_permissions": false,
"created_at": "now", "updated_at": "now"
}))
.expect("minimal project deserializes");
assert_eq!(ContainerTarget::for_project(&p), None);
p.container_id = Some("abc".into());
assert_eq!(
ContainerTarget::for_project(&p),
Some(ContainerTarget::docker("abc"))
);
assert_eq!(ContainerTarget::docker("abc").id(), "abc");
}
/// Phase 1's boundary, pinned: outside `docker/` and `host/`, only the
/// files listed here may name bollard or `docker::exec`. Each entry is
/// Docker-only by nature (migration, snapshots, sidecars) or is scheduled
/// for phase 1b (lifecycle, the `die` event stream). Shrink this list; do
/// not grow it — new container-interior code goes through
/// [`ContainerTarget`].
#[test]
fn no_container_interior_bollard_outside_the_host_layer() {
const ALLOWED: &[&str] = &[
// Docker-only: base-image migration replays apt / restores a payload.
"commands/migration_commands.rs",
// Phase 1b: lifecycle and the `die` event stream.
"container_watch.rs",
];
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut offenders = Vec::new();
let mut stack = vec![src.clone()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).unwrap() {
let path = entry.unwrap().path();
let rel = path
.strip_prefix(&src)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
if path.is_dir() {
if rel != "docker" && rel != "host" {
stack.push(path);
}
continue;
}
if !rel.ends_with(".rs") || ALLOWED.contains(&rel.as_str()) {
continue;
}
let text = std::fs::read_to_string(&path).unwrap();
if text.contains("bollard::")
|| text.contains("docker::exec")
|| text.contains("get_docker")
{
offenders.push(rel);
}
}
}
offenders.sort();
assert!(
offenders.is_empty(),
"these files reach into the container without going through host::ContainerTarget: {:?}",
offenders
);
}
}
+214
View File
@@ -0,0 +1,214 @@
//! Interactive exec sessions — terminals and the audio bridge — keyed by the
//! frontend's session id. Host-neutral: every session holds the
//! [`ContainerTarget`] it was opened on and does all its I/O through it.
//! (Moved here from `docker/exec.rs` in phase 1 of the AWS host work.)
use futures_util::StreamExt;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::sync::{mpsc, Mutex};
use super::{AttachedExec, ContainerTarget};
pub struct ExecSession {
pub exec_id: String,
pub target: ContainerTarget,
pub input_tx: mpsc::UnboundedSender<Vec<u8>>,
shutdown_tx: mpsc::Sender<()>,
}
impl ExecSession {
pub async fn send_input(&self, data: Vec<u8>) -> Result<(), String> {
self.input_tx
.send(data)
.map_err(|e| format!("Failed to send input: {}", e))
}
#[allow(dead_code)]
pub async fn resize(&self, cols: u16, rows: u16) -> Result<(), String> {
self.target.resize_exec(&self.exec_id, cols, rows).await
}
pub fn shutdown(&self) {
let _ = self.shutdown_tx.try_send(());
}
}
pub struct ExecSessionManager {
sessions: Arc<Mutex<HashMap<String, ExecSession>>>,
}
impl ExecSessionManager {
pub fn new() -> Self {
Self {
sessions: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn create_session<F>(
&self,
target: &ContainerTarget,
session_id: &str,
cmd: Vec<String>,
on_output: F,
on_exit: Box<dyn FnOnce() + Send>,
) -> Result<(), String>
where
F: Fn(Vec<u8>) + Send + 'static,
{
self.create_session_with_tty(target, session_id, cmd, true, on_output, on_exit)
.await
}
pub async fn create_session_with_tty<F>(
&self,
target: &ContainerTarget,
session_id: &str,
cmd: Vec<String>,
tty: bool,
on_output: F,
on_exit: Box<dyn FnOnce() + Send>,
) -> Result<(), String>
where
F: Fn(Vec<u8>) + Send + 'static,
{
let AttachedExec {
exec_id,
mut output,
mut input,
} = target.attach_exec(cmd, tty).await?;
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
// Output reader task
let session_id_clone = session_id.to_string();
let shutdown_tx_clone = shutdown_tx.clone();
tokio::spawn(async move {
loop {
tokio::select! {
msg = output.next() => {
match msg {
Some(Ok(output)) => {
on_output(output.into_bytes().to_vec());
}
Some(Err(e)) => {
log::error!("Exec output error for {}: {}", session_id_clone, e);
break;
}
None => {
log::info!("Exec output stream ended for {}", session_id_clone);
break;
}
}
}
_ = shutdown_rx.recv() => {
log::info!("Exec session {} shutting down", session_id_clone);
break;
}
}
}
on_exit();
let _ = shutdown_tx_clone;
});
// Input writer task
tokio::spawn(async move {
while let Some(data) = input_rx.recv().await {
if let Err(e) = input.write_all(&data).await {
log::error!("Failed to write to exec stdin: {}", e);
break;
}
}
});
let session = ExecSession {
exec_id,
target: target.clone(),
input_tx,
shutdown_tx,
};
self.sessions
.lock()
.await
.insert(session_id.to_string(), session);
Ok(())
}
pub async fn send_input(&self, session_id: &str, data: Vec<u8>) -> Result<(), String> {
let sessions = self.sessions.lock().await;
let session = sessions
.get(session_id)
.ok_or_else(|| format!("Session {} not found", session_id))?;
session.send_input(data).await
}
pub async fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
// Clone the exec_id under the lock, then drop the lock before the
// async host call to avoid holding the mutex across await.
let (exec_id, target) = {
let sessions = self.sessions.lock().await;
let session = sessions
.get(session_id)
.ok_or_else(|| format!("Session {} not found", session_id))?;
(session.exec_id.clone(), session.target.clone())
};
target.resize_exec(&exec_id, cols, rows).await
}
pub async fn close_session(&self, session_id: &str) {
let mut sessions = self.sessions.lock().await;
if let Some(session) = sessions.remove(session_id) {
session.shutdown();
}
}
pub async fn close_sessions_for_container(&self, container_id: &str) {
let mut sessions = self.sessions.lock().await;
let ids_to_close: Vec<String> = sessions
.iter()
.filter(|(_, s)| s.target.id() == container_id)
.map(|(id, _)| id.clone())
.collect();
for id in ids_to_close {
if let Some(session) = sessions.remove(&id) {
session.shutdown();
}
}
}
pub async fn close_all_sessions(&self) {
let mut sessions = self.sessions.lock().await;
for (_, session) in sessions.drain() {
session.shutdown();
}
}
/// The container a session was opened on — reuse it for side-channel work
/// on the same session (image paste, file drop, audio bridge) rather than
/// rebuilding a target from an id, which would assume the host.
pub async fn get_target(&self, session_id: &str) -> Result<ContainerTarget, String> {
let sessions = self.sessions.lock().await;
let session = sessions
.get(session_id)
.ok_or_else(|| format!("Session {} not found", session_id))?;
Ok(session.target.clone())
}
pub async fn write_file_to_container(
&self,
target: &ContainerTarget,
file_name: &str,
data: &[u8],
) -> Result<String, String> {
// Owned by the container user, stamped now: a default tar header would
// land it as root:root/1970 and Claude Code could not rewrite it.
let ids = target.user_ids().await;
target
.upload_bytes_with_ids("/tmp", file_name, data, 0o644, ids)
.await
}
}
+2 -1
View File
@@ -7,6 +7,7 @@ mod container_watch;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod linux_media; mod linux_media;
mod docker; mod docker;
mod host;
pub mod file_viewer; pub mod file_viewer;
mod install_helper; mod install_helper;
mod logging; mod logging;
@@ -22,7 +23,7 @@ use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use auth_bridge::AuthBridgeManager; use auth_bridge::AuthBridgeManager;
use docker::exec::ExecSessionManager; use host::ExecSessionManager;
use storage::projects_store::ProjectsStore; use storage::projects_store::ProjectsStore;
use storage::settings_store::SettingsStore; use storage::settings_store::SettingsStore;
use tauri::async_runtime::JoinHandle; use tauri::async_runtime::JoinHandle;
+26 -33
View File
@@ -5,16 +5,13 @@
use std::time::Duration; use std::time::Duration;
use bollard::container::LogOutput;
use futures_util::{Stream, StreamExt}; use futures_util::{Stream, StreamExt};
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use tokio::io::{AsyncWrite, AsyncWriteExt}; use tokio::io::{AsyncWrite, AsyncWriteExt};
use tokio::sync::oneshot; use tokio::sync::oneshot;
use crate::commands::auth_token_commands::{push_capped_tail, AnsiStripper, SUBMIT_ENTER_DELAY}; use crate::commands::auth_token_commands::{push_capped_tail, AnsiStripper, SUBMIT_ENTER_DELAY};
use crate::docker::exec::{ use crate::host::{AttachedExec, ContainerTarget, ExecChunk, ExecSpec};
create_attached_exec_as, exec_oneshot_as, wait_for_exec_exit, AttachedExec,
};
pub const CODE_EVENT: &str = "marketplace-gh-login-code"; pub const CODE_EVENT: &str = "marketplace-gh-login-code";
pub const OUTPUT_EVENT: &str = "marketplace-gh-login-output"; 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. /// script around it (whose text contains the same words); errors are ignored.
const CANCEL_PKILL: [&str; 3] = ["pkill", "-f", "gh auth login --hostname"]; const CANCEL_PKILL: [&str; 3] = ["pkill", "-f", "gh auth login --hostname"];
/// Constant script; the host is `$1` (argv, never interpolated), because /// Constant script; the host is `$1` (argv, never interpolated); the attached
/// `create_attached_exec_as` takes no env. /// exec is opened with no extra env.
/// ///
/// * `GH_CONFIG_DIR` / `GIT_CONFIG_GLOBAL` live in a temp dir removed on exit, /// * `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 /// 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 /// Pre-flight N9 / review fix 1: stop the in-container login after any
/// failed attempt. /// 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 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. /// Hand `result` back, running `cleanup` first when it is a failure.
@@ -187,7 +184,7 @@ async fn drive_login<S, W, E>(
mut emit: E, mut emit: E,
) -> Result<String, String> ) -> Result<String, String>
where where
S: Stream<Item = Result<LogOutput, bollard::errors::Error>> + Unpin, S: Stream<Item = Result<ExecChunk, String>> + Unpin,
W: AsyncWrite + Unpin, W: AsyncWrite + Unpin,
E: FnMut(&'static str, serde_json::Value), E: FnMut(&'static str, serde_json::Value),
{ {
@@ -262,7 +259,7 @@ where
pub async fn run_gh_container_login( pub async fn run_gh_container_login(
app: &AppHandle, app: &AppHandle,
account_id: &str, account_id: &str,
container_id: &str, target: &ContainerTarget,
host: &str, host: &str,
mut cancel: oneshot::Receiver<()>, mut cancel: oneshot::Receiver<()>,
) -> Result<String, String> { ) -> Result<String, String> {
@@ -273,20 +270,20 @@ pub async fn run_gh_container_login(
exec_id, exec_id,
output, output,
input, input,
} = create_attached_exec_as( } = target
container_id, .attach(
vec![ ExecSpec::new(vec![
"sh".to_string(), "sh".to_string(),
"-c".to_string(), "-c".to_string(),
GH_LOGIN_SCRIPT.to_string(), GH_LOGIN_SCRIPT.to_string(),
"triple-c-gh-login".to_string(), "triple-c-gh-login".to_string(),
host.to_string(), host.to_string(),
], ])
true, .tty(true)
"claude", .user("claude")
"/home/claude", .working_dir(Some("/home/claude")),
) )
.await?; .await?;
let deadline = tokio::time::Instant::now() + LOGIN_TIMEOUT; let deadline = tokio::time::Instant::now() + LOGIN_TIMEOUT;
let result = async { let result = async {
@@ -305,7 +302,7 @@ pub async fn run_gh_container_login(
if let Some(token) = extract_token(&transcript) { if let Some(token) = extract_token(&transcript) {
return Ok(token); return Ok(token);
} }
let status = wait_for_exec_exit(&exec_id).await; let status = target.wait_exec_exit(&exec_id).await;
Err(format!( Err(format!(
"gh did not complete the sign-in (exit status {}). Nothing was stored.\n{}", "gh did not complete the sign-in (exit status {}). Nothing was stored.\n{}",
status status
@@ -315,7 +312,7 @@ pub async fn run_gh_container_login(
)) ))
} }
.await; .await;
cleanup_on_error(result, || kill_container_login(container_id)).await cleanup_on_error(result, || kill_container_login(target)).await
} }
#[cfg(test)] #[cfg(test)]
@@ -496,23 +493,19 @@ mod tests {
/// successful one does not. /// successful one does not.
mod teardown { mod teardown {
use super::super::*; use super::super::*;
use bollard::container::LogOutput;
use futures_util::stream; use futures_util::stream;
use std::pin::Pin; use std::pin::Pin;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::task::{Context, Poll}; use std::task::{Context, Poll};
type Frame = Result<LogOutput, bollard::errors::Error>; type Frame = Result<ExecChunk, String>;
fn out(s: &'static str) -> Frame { fn out(s: &'static str) -> Frame {
Ok(LogOutput::StdOut { message: s.into() }) Ok(ExecChunk::StdOut(s.into()))
} }
fn lost() -> Frame { fn lost() -> Frame {
Err(bollard::errors::Error::DockerResponseServerError { Err("connection reset".to_string())
status_code: 500,
message: "connection reset".to_string(),
})
} }
/// Records every write separately; or fails every write. /// Records every write separately; or fails every write.
+19 -23
View File
@@ -4,7 +4,7 @@
use std::time::Duration; use std::time::Duration;
use super::payload::Payload; 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}; use crate::models::marketplace::{SkippedItem, SyncReport};
/// Where the payload and the script are uploaded. Owned by `claude`. /// Where the payload and the script are uploaded. Owned by `claude`.
@@ -57,10 +57,12 @@ fn run_script_env() -> Vec<String> {
vec!["HOME=/home/claude".to_string()] 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; let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
loop { 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 { if code == 0 {
return Ok(()); 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 /// Wait for readiness, upload the payload and the script, run the script as
/// `claude`, and return its report. /// `claude`, and return its report.
pub async fn sync_container(container_id: &str, payload: &Payload) -> Result<SyncReport, String> { pub async fn sync_container(container_id: &str, payload: &Payload) -> Result<SyncReport, String> {
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 { if code != 0 {
return Err(format!( return Err(format!(
"Could not prepare the container for the marketplace sync: {}", "Could not prepare the container for the marketplace sync: {}",
tail(&out, 500) tail(&out, 500)
)); ));
} }
upload_bytes_to_container( target
container_id, .upload_bytes(INCOMING_DIR, "payload.tar", &payload.tar, 0o644)
INCOMING_DIR, .await?;
"payload.tar", target
&payload.tar, .upload_bytes(INCOMING_DIR, "sync.sh", SYNC_SCRIPT.as_bytes(), 0o755)
0o644, .await?;
)
.await?;
upload_bytes_to_container(
container_id,
INCOMING_DIR,
"sync.sh",
SYNC_SCRIPT.as_bytes(),
0o755,
)
.await?;
let (stdout, stderr, code) = let (stdout, stderr, code) = target
exec_oneshot_streams_as(container_id, "claude", run_script_cmd(), run_script_env()).await?; .exec_oneshot_streams_as("claude", run_script_cmd(), run_script_env())
.await?;
parse_report(&stdout).map_err(|e| { parse_report(&stdout).map_err(|e| {
format!( format!(
"The marketplace sync script failed (exit {code}): {e}. {}", "The marketplace sync script failed (exit {code}): {e}. {}",
+1 -1
View File
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use tokio::sync::watch; use tokio::sync::watch;
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
use crate::docker::exec::ExecSessionManager; use crate::host::ExecSessionManager;
use crate::storage::projects_store::ProjectsStore; use crate::storage::projects_store::ProjectsStore;
use crate::storage::settings_store::SettingsStore; use crate::storage::settings_store::SettingsStore;
+8 -4
View File
@@ -295,9 +295,7 @@ async fn handle_open(
return Err(format!("Project '{}' is not running", project.name)); return Err(format!("Project '{}' is not running", project.name));
} }
let container_id = project let container = crate::host::ContainerTarget::for_project(&project)
.container_id
.as_ref()
.ok_or_else(|| "Container not running".to_string())?; .ok_or_else(|| "Container not running".to_string())?;
let cmd = match session_type { let cmd = match session_type {
@@ -329,7 +327,13 @@ async fn handle_open(
state state
.exec_manager .exec_manager
.create_session(container_id, &session_id, cmd, on_output, on_exit) .create_session(
&container,
&session_id,
cmd,
on_output,
on_exit,
)
.await?; .await?;
// Track this session for cleanup on disconnect // Track this session for cleanup on disconnect