Route auth bridge, browser view and terminal commands through ContainerTarget

Phase 1a of the AWS host work: these files no longer name bollard or
docker::exec; every exec, upload and is-running check goes through
host::ContainerTarget. No behaviour change: same user, working dir, env,
tty and stdin attachment for every exec.

- auth_bridge: the poller, reconcile and PortForward hold a ContainerTarget
  (AuthBridgeManager::start keeps its container_id signature).
  tunnel_connection_with_prelude keeps its String signature for the
  browser-view proxy and wraps the new tunnel_into_with_prelude.
- browser_view: the supervisor and viewer helpers take a ContainerTarget;
  detect::detect_in is the target-taking form of detect; install.rs's
  run_step uses ContainerTarget::attach with an ExecSpec equal to the old
  create_attached_exec_as call. page.rs builds the target at each exec.
- terminal_commands: open_terminal_session uses ContainerTarget::for_project;
  session-derived calls build ContainerTarget::docker from the session's id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-13 10:30:52 -08:00
1 parent 209b8855a4
commit 229dc2cefc
7 files changed
+209 -169

No files matched your search

+16 -11
View File
@@ -32,7 +32,7 @@
//! |---|---|
//! | Bridge disabled | `set_auth_bridge_enabled(false)` → [`AuthBridgeManager::stop`] |
//! | Container stopped via UI | `stop_project_container` → [`AuthBridgeManager::stop`] |
//! | Container stopped/died another way | poller's own `is_container_running` check → loop exits |
//! | Container stopped/died another way | poller's own `is_running` check → loop exits |
//! | Project deleted | `remove_project` → [`AuthBridgeManager::stop`]; also the poller's `store.get()` check |
//! | Container rebuilt | `rebuild_project_container` → stop, then start re-arms it |
//! | App exit | window `CloseRequested` → [`AuthBridgeManager::stop_all`] |
@@ -55,8 +55,7 @@ use tauri::{AppHandle, Emitter};
use tokio::sync::{watch, Mutex};
use tokio::task::JoinHandle;
use crate::docker::container::is_container_running;
use crate::docker::exec::{exec_oneshot_limited, PROC_NET_OUTPUT_LIMIT};
use crate::host::{ContainerTarget, PROC_NET_OUTPUT_LIMIT};
use crate::storage::projects_store::ProjectsStore;
use proc_net::PortFamily;
@@ -205,7 +204,7 @@ impl AuthBridgeManager {
let poller = tokio::spawn(poll_loop(
project_id.clone(),
container_id,
ContainerTarget::docker(container_id),
epoch,
app,
store,
@@ -276,7 +275,7 @@ impl AuthBridgeManager {
#[allow(clippy::too_many_arguments)]
async fn poll_loop(
project_id: String,
container_id: String,
container: ContainerTarget,
epoch: u64,
app: AppHandle,
store: Arc<ProjectsStore>,
@@ -301,7 +300,7 @@ async fn poll_loop(
log::info!("Auth bridge: disabled for project {} — tearing down", project_id);
break;
}
if !is_container_running(&container_id).await.unwrap_or(false) {
if !container.is_running().await.unwrap_or(false) {
log::info!(
"Auth bridge: container for project {} is no longer running — tearing down",
project_id
@@ -327,7 +326,7 @@ async fn poll_loop(
// bridge or stopping the container doesn't wait out an in-flight poll.
let discovery = tokio::select! {
_ = cancel.changed() => break,
res = exec_oneshot_limited(&container_id, "claude", cmd, PROC_NET_OUTPUT_LIMIT) => {
res = container.exec_oneshot_limited("claude", cmd, PROC_NET_OUTPUT_LIMIT) => {
res.map(|(out, _)| out)
}
};
@@ -340,7 +339,7 @@ async fn poll_loop(
// gateway/STT/web-terminal ports can be re-pointed while the
// bridge is running, and a stale reservation set is a hole.
let skip = skipped_ports(&project, &store.list(), &app_settings(&app));
if reconcile(&container_id, &discovered, &skip, &state).await {
if reconcile(&container, &discovered, &skip, &state).await {
emit_status(&app, &project_id, &state, true).await;
}
}
@@ -530,7 +529,7 @@ const MAX_CONFLICTS: usize = 32;
/// Bring the set of host listeners in line with what the container is currently
/// listening on. Returns whether anything the UI cares about changed.
async fn reconcile(
container_id: &str,
container: &ContainerTarget,
discovered: &BTreeMap<u16, PortFamily>,
skip: &HashSet<u16>,
state: &Arc<Mutex<BridgeState>>,
@@ -583,7 +582,7 @@ async fn reconcile(
);
continue;
}
match PortForward::bind(container_id.to_string(), port, family).await {
match PortForward::bind(container.clone(), port, family).await {
Ok(forward) => {
if st.conflicts.remove(&port).is_some() {
log::info!("Auth bridge: host port {} became available", port);
@@ -833,7 +832,13 @@ mod tests {
(45000u16..45200).map(|p| (p, PortFamily::V4)).collect();
let state = Arc::new(Mutex::new(BridgeState::default()));
reconcile("no-such-container", &discovered, &HashSet::new(), &state).await;
reconcile(
&ContainerTarget::docker("no-such-container"),
&discovered,
&HashSet::new(),
&state,
)
.await;
let mut st = state.lock().await;
assert!(
+37 -12
View File
@@ -11,7 +11,7 @@
//! and stdout wired to the TCP socket. `socat` ships in the container image.
//!
//! The exec plumbing itself is *not* reimplemented here: it comes from
//! [`crate::docker::exec::create_attached_exec`], the same helper the
//! [`crate::host::ContainerTarget::attach_exec`], the same helper the
//! interactive terminal sessions are built on.
//!
//! ## What the host listener is, and is not
@@ -56,14 +56,12 @@
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
use std::time::Duration;
use crate::host::ExecChunk;
use crate::host::{AttachedExec, ContainerTarget, ExecChunk};
use futures_util::StreamExt;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::task::{JoinHandle, JoinSet};
use crate::docker::exec::{create_attached_exec, AttachedExec};
use super::proc_net::PortFamily;
/// Buffer size for the host→container direction. OAuth callbacks are tiny; this
@@ -143,13 +141,13 @@ impl Drop for PortForward {
}
impl PortForward {
/// Bind `port` on the host loopback and start proxying into `container_id`.
/// Bind `port` on the host loopback and start proxying into `container`.
///
/// The bind happens before the task is spawned, so an already-taken port is
/// reported to the caller as an error rather than disappearing into a
/// background task.
pub async fn bind(
container_id: String,
container: ContainerTarget,
port: u16,
family: PortFamily,
) -> Result<Self, std::io::Error> {
@@ -196,7 +194,7 @@ impl PortForward {
};
let target = family.socat_target(port);
let task = tokio::spawn(accept_loop(container_id, port, target, v4, v6));
let task = tokio::spawn(accept_loop(container, port, target, v4, v6));
Ok(Self {
port,
@@ -219,7 +217,7 @@ impl PortForward {
/// Accept on both loopback listeners until aborted. Dropping this future drops
/// the listeners (freeing the port) and the `JoinSet` (aborting live tunnels).
async fn accept_loop(
container_id: String,
container: ContainerTarget,
port: u16,
target: String,
v4: TcpListener,
@@ -258,7 +256,7 @@ async fn accept_loop(
log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port);
let _ = stream.set_nodelay(true);
conns.spawn(tunnel_connection(
container_id.clone(),
container.clone(),
target.clone(),
stream,
port,
@@ -288,7 +286,12 @@ async fn accept_optional(
///
/// Nothing is forwarded until that decision is made, so a refused request never
/// reaches the container at all — not even a `docker exec`.
async fn tunnel_connection(container_id: String, target: String, mut stream: TcpStream, port: u16) {
async fn tunnel_connection(
container: ContainerTarget,
target: String,
mut stream: TcpStream,
port: u16,
) {
let head = match read_leading_bytes(&mut stream).await {
Ok(head) => head,
Err(e) => {
@@ -319,7 +322,7 @@ async fn tunnel_connection(container_id: String, target: String, mut stream: Tcp
}
// The bytes already off the socket go back on the wire first, byte-exact.
tunnel_connection_with_prelude(container_id, target, stream, port, head.into_buffer()).await
tunnel_into_with_prelude(container, target, stream, port, head.into_buffer()).await
}
/// What the first bytes of an accepted connection turned out to be.
@@ -511,12 +514,34 @@ pub(crate) fn web_request_verdict(head: &str) -> Verdict {
/// head off the socket to check a token, and then has to put those same bytes
/// back on the wire. Passing them here keeps the byte stream exact, rather than
/// re-serialising a parsed request.
///
/// Takes a Docker container id because that is what the browser-view proxy
/// holds; [`tunnel_into_with_prelude`] is the same thing for a
/// [`ContainerTarget`] already in hand.
pub async fn tunnel_connection_with_prelude(
container_id: String,
target: String,
stream: TcpStream,
port: u16,
prelude: Vec<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()];
@@ -524,7 +549,7 @@ pub async fn tunnel_connection_with_prelude(
mut output,
mut input,
..
} = match create_attached_exec(&container_id, cmd, false).await {
} = match container.attach_exec(cmd, false).await {
Ok(e) => e,
Err(e) => {
log::warn!(
+13 -6
View File
@@ -36,7 +36,7 @@
use serde::{Deserialize, Serialize};
use crate::docker::exec::exec_oneshot;
use crate::host::ContainerTarget;
/// Marks the JSON payload in the probe's stdout, so unrelated chatter on the
/// same stream (npm notices, Node warnings) can't be mistaken for the result.
@@ -278,11 +278,18 @@ impl PlaywrightDetection {
/// `npx @playwright/mcp`, is found as readily as one in
/// `/workspace/node_modules`.
pub async fn detect(container_id: &str) -> Result<PlaywrightDetection, String> {
let output = exec_oneshot(
container_id,
vec!["node".to_string(), "-e".to_string(), PROBE.to_string()],
)
.await?;
detect_in(&ContainerTarget::docker(container_id)).await
}
/// [`detect`] against a target already in hand.
pub async fn detect_in(container: &ContainerTarget) -> Result<PlaywrightDetection, String> {
let output = container
.exec_oneshot(vec![
"node".to_string(),
"-e".to_string(),
PROBE.to_string(),
])
.await?;
parse_probe_output(&output)
}
+43 -34
View File
@@ -67,9 +67,7 @@ use serde::Serialize;
use tauri::AppHandle;
use crate::commands::project_commands::emit_progress;
use crate::docker::exec::{
create_attached_exec_as, exec_oneshot_as, wait_for_exec_exit, AttachedExec,
};
use crate::host::{AttachedExec, ContainerTarget, ExecSpec};
use super::detect::{self, PlaywrightDetection};
@@ -242,13 +240,14 @@ pub async fn install_packages(
project_id: &str,
container_id: &str,
) -> Result<BrowserSetupOutcome, String> {
let container = ContainerTarget::docker(container_id);
emit_progress(
app,
project_id,
&format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR),
);
let mut step = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE]).await?;
let mut step = npm_install(app, project_id, &container, &[VIEWER_PACKAGE]).await?;
if step.exit_code != 0 {
return Err(format!(
"npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}",
@@ -265,9 +264,9 @@ pub async fn install_packages(
// see the note on `npm_install`. The pin can only be read after the first
// install has written the manifest, which is why this stays two commands
// rather than one.
let spec = pinned_playwright_spec(container_id).await;
let spec = pinned_playwright_spec(&container).await;
emit_progress(app, project_id, &format!("Installing {}…", spec));
let second = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE, &spec]).await?;
let second = npm_install(app, project_id, &container, &[VIEWER_PACKAGE, &spec]).await?;
if second.exit_code != 0 {
return Err(format!(
"npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}",
@@ -279,7 +278,7 @@ pub async fn install_packages(
step.log = merge_logs(step.log, second.log);
emit_progress(app, project_id, "Re-checking what the container has…");
let detection = detect::detect(container_id).await?;
let detection = detect::detect_in(&container).await?;
// A probe that still finds something missing after a successful npm run is
// the interesting case, so it is surfaced rather than swallowed. And a
@@ -331,7 +330,7 @@ pub async fn install_packages(
async fn npm_install(
app: &AppHandle,
project_id: &str,
container_id: &str,
container: &ContainerTarget,
specs: &[&str],
) -> Result<StepResult, String> {
let mut cmd = vec![
@@ -348,7 +347,7 @@ async fn npm_install(
run_step(
app,
project_id,
container_id,
container,
"claude",
INSTALL_DIR,
cmd,
@@ -363,20 +362,20 @@ async fn npm_install(
/// Read from the manifest npm just wrote rather than guessed, and falling back
/// to `@latest` when it can't be read — an unreadable manifest is a reason to
/// install something, not nothing.
async fn pinned_playwright_spec(container_id: &str) -> String {
async fn pinned_playwright_spec(container: &ContainerTarget) -> String {
let script = format!(
"try{{const d=require('{}/node_modules/@playwright/cli/package.json').dependencies||{{}};\
process.stdout.write(d.playwright||'');}}catch(e){{}}",
INSTALL_DIR
);
let (out, _code) = exec_oneshot_as(
container_id,
"claude",
vec!["node".to_string(), "-e".to_string(), script],
Vec::new(),
)
.await
.unwrap_or_default();
let (out, _code) = container
.exec_oneshot_as(
"claude",
vec!["node".to_string(), "-e".to_string(), script],
Vec::new(),
)
.await
.unwrap_or_default();
let version: &str = out.trim();
// A version, not a range or a URL: anything else goes to the fallback
@@ -414,7 +413,8 @@ pub async fn install_browser(
container_id: &str,
target: BrowserTarget,
) -> Result<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 {
return Err(
"Playwright isn't installed in this container yet, so there is nothing to install a \
@@ -458,7 +458,7 @@ pub async fn install_browser(
project_id,
"Step 1/3 — checking whether this container already has the browser system libraries…",
);
let state = check_libraries(container_id, &cli, target).await;
let state = check_libraries(&container, &cli, target).await;
match &state {
LibraryState::Present => {
emit_progress(
@@ -482,7 +482,7 @@ pub async fn install_browser(
let deps = run_step(
app,
project_id,
container_id,
&container,
"root",
"/tmp",
vec![
@@ -530,7 +530,7 @@ pub async fn install_browser(
let dl = run_step(
app,
project_id,
container_id,
&container,
user,
workdir,
vec![
@@ -559,7 +559,7 @@ pub async fn install_browser(
project_id,
&format!("Step 3/3 — checking that {} actually launches…", target.cli_name()),
);
let verdict = verify_launch(container_id, &before, target).await;
let verdict = verify_launch(&container, &before, target).await;
push_section(&mut log, &verdict.detail);
if !verdict.ok {
warning = merge(
@@ -590,7 +590,7 @@ pub async fn install_browser(
}
emit_progress(app, project_id, "Re-checking what the container has…");
let detection = detect::detect(container_id).await?;
let detection = detect::detect_in(&container).await?;
Ok(BrowserSetupOutcome {
detection,
@@ -642,9 +642,12 @@ impl LibraryState {
/// dpkg's status file. A *missing* package on such an image is simply not in
/// any index, so apt fails, Playwright reports the failure, and this returns
/// [`LibraryState::Unknown`] — which installs, which is the right answer.
async fn check_libraries(container_id: &str, cli: &str, target: BrowserTarget) -> LibraryState {
let run = exec_oneshot_as(
container_id,
async fn check_libraries(
container: &ContainerTarget,
cli: &str,
target: BrowserTarget,
) -> LibraryState {
let run = container.exec_oneshot_as(
"root",
vec![
"node".to_string(),
@@ -708,7 +711,7 @@ struct LaunchVerdict {
/// Launch the browser headless, load one page, and close it. Seconds, and it is
/// the only thing that distinguishes "downloaded" from "usable".
async fn verify_launch(
container_id: &str,
container: &ContainerTarget,
detection: &PlaywrightDetection,
target: BrowserTarget,
) -> LaunchVerdict {
@@ -724,8 +727,7 @@ async fn verify_launch(
};
let dir = manifest.trim_end_matches("package.json").trim_end_matches('/');
let run = exec_oneshot_as(
container_id,
let run = container.exec_oneshot_as(
"claude",
vec![
"node".to_string(),
@@ -884,13 +886,13 @@ impl StepResult {
/// Run one command in the container, streaming every line it prints to the pane
/// as `container-progress` and keeping the tail for the result.
///
/// Uses `create_attached_exec_as` — the single attached-exec path — rather than
/// Uses [`ContainerTarget::attach`] — the single attached-exec path — rather than
/// `exec_oneshot`, because these commands run for minutes and the point is that
/// the user can watch them.
async fn run_step(
app: &AppHandle,
project_id: &str,
container_id: &str,
container: &ContainerTarget,
user: &str,
workdir: &str,
cmd: Vec<String>,
@@ -900,7 +902,14 @@ async fn run_step(
exec_id,
mut output,
input,
} = create_attached_exec_as(container_id, cmd, false, user, workdir).await?;
} = container
.attach(
ExecSpec::new(cmd)
.tty(false)
.user(user)
.working_dir(Some(workdir)),
)
.await?;
// Nothing is ever written to this exec. Closing stdin means anything that
// would prompt sees EOF and gives up, instead of waiting for a person who
@@ -944,7 +953,7 @@ async fn run_step(
)),
Ok(Err(e)) => Err(e),
Ok(Ok(())) => Ok(StepResult {
exit_code: wait_for_exec_exit(&exec_id).await.unwrap_or(0),
exit_code: container.wait_exec_exit(&exec_id).await.unwrap_or(0),
log,
}),
}
+55 -54
View File
@@ -50,7 +50,7 @@
//! | Trigger | Path |
//! |---|---|
//! | Turned off in the UI | `set_browser_view_enabled(false)` → persist `false`, then [`BrowserViewManager::stop`] |
//! | Container stopped, by the UI or otherwise | supervisor's `is_container_running` check |
//! | Container stopped, by the UI or otherwise | supervisor's `is_running` check |
//! | Project deleted | supervisor's `store.get()` check |
//! | Container rebuilt | old container stops → supervisor exits; the new one is not auto-started |
//! | Viewer died in the container | supervisor's periodic HTTP liveness probe |
@@ -90,8 +90,7 @@ use tokio::sync::{watch, Mutex};
use tokio::task::JoinHandle;
use crate::auth_bridge::proc_net::{self, PortFamily};
use crate::docker::container::is_container_running;
use crate::docker::exec::exec_oneshot;
use crate::host::ContainerTarget;
use crate::storage::projects_store::ProjectsStore;
use detect::PlaywrightDetection;
@@ -300,7 +299,8 @@ impl BrowserViewManager {
return Ok(self.status(&project_id, true).await);
}
let detection = detect::detect(&container_id).await?;
let container = ContainerTarget::docker(container_id.as_str());
let detection = detect::detect_in(&container).await?;
if !detection.is_usable() {
let blocker = detection.blocker().unwrap_or_else(|| {
"Playwright is present but incomplete in this container.".to_string()
@@ -320,9 +320,9 @@ impl BrowserViewManager {
// Doing this before choosing a port also frees the one a previous
// session was using, so sessions don't walk up the range. Best-effort:
// a container with no dashboard makes this a no-op.
let _ = kill_dashboard(&container_id, &cli_entry).await;
let _ = kill_dashboard(&container, &cli_entry).await;
let (container_port, entry_path) = start_viewer(&container_id, &cli_entry).await?;
let (container_port, entry_path) = start_viewer(&container, &cli_entry).await?;
let token = generate_token();
// `--host 127.0.0.1` is ours to set, so the family is known and there is
@@ -337,7 +337,7 @@ impl BrowserViewManager {
{
Ok(p) => p,
Err(e) => {
let _ = kill_dashboard(&container_id, &cli_entry).await;
let _ = kill_dashboard(&container, &cli_entry).await;
return Err(e);
}
};
@@ -354,7 +354,7 @@ impl BrowserViewManager {
let (cancel_tx, cancel_rx) = watch::channel(false);
let supervisor = tokio::spawn(supervise(
project_id.clone(),
container_id.clone(),
container,
cli_entry,
container_port,
epoch,
@@ -427,7 +427,7 @@ impl BrowserViewManager {
#[allow(clippy::too_many_arguments)]
async fn supervise(
project_id: String,
container_id: String,
container: ContainerTarget,
cli_entry: String,
container_port: u16,
epoch: u64,
@@ -443,7 +443,7 @@ async fn supervise(
log::info!("Browser view: project {} is gone — tearing down", project_id);
break;
}
if !is_container_running(&container_id).await.unwrap_or(false) {
if !container.is_running().await.unwrap_or(false) {
log::info!(
"Browser view: container for project {} is no longer running — tearing down",
project_id
@@ -459,7 +459,7 @@ async fn supervise(
// view never waits out an in-flight exec.
let alive = tokio::select! {
_ = cancel.changed() => break,
res = probe_entry_path(&container_id, container_port) => res.is_ok(),
res = probe_entry_path(&container, container_port) => res.is_ok(),
};
if !alive {
log::warn!(
@@ -477,7 +477,7 @@ async fn supervise(
}
proxy.shutdown().await;
let _ = kill_dashboard(&container_id, &cli_entry).await;
let _ = kill_dashboard(&container, &cli_entry).await;
// Deregister, unless a newer session has already taken this project's slot.
let superseded = {
@@ -538,7 +538,11 @@ const VIEWER_LOG: &str = "/tmp/triple-c-browser-view.log";
/// redirected to [`VIEWER_LOG`] (both so `exec_oneshot` can return immediately
/// rather than waiting on an inherited stdout, and so a failure has a trail),
/// and readiness is established by [`wait_until_ready`] instead.
async fn launch_viewer(container_id: &str, cli_entry: &str, port: u16) -> Result<(), String> {
async fn launch_viewer(
container: &ContainerTarget,
cli_entry: &str,
port: u16,
) -> Result<(), String> {
// `NO_UPDATE_NOTIFIER` stops the CLI phoning registry.npmjs.org on every
// launch; the container may have no egress, and we don't want to wait out a
// DNS timeout before the dashboard binds.
@@ -549,13 +553,11 @@ async fn launch_viewer(container_id: &str, cli_entry: &str, port: u16) -> Result
port,
VIEWER_LOG
);
exec_oneshot(
container_id,
vec!["sh".to_string(), "-c".to_string(), script],
)
.await
.map(|_| ())
.map_err(|e| format!("Could not start the Playwright viewer: {}", e))
container
.exec_oneshot(vec!["sh".to_string(), "-c".to_string(), script])
.await
.map(|_| ())
.map_err(|e| format!("Could not start the Playwright viewer: {}", e))
}
/// The dashboard singleton is keyed on a hash of the working directory, so
@@ -567,17 +569,15 @@ async fn launch_viewer(container_id: &str, cli_entry: &str, port: u16) -> Result
const WORKDIR_PREFIX: &str = "cd /workspace 2>/dev/null || true";
/// Stop the dashboard daemon. Verified to free the port and stop answering.
async fn kill_dashboard(container_id: &str, cli_entry: &str) -> Result<String, String> {
async fn kill_dashboard(container: &ContainerTarget, cli_entry: &str) -> Result<String, String> {
let script = format!(
"{}; NO_UPDATE_NOTIFIER=1 node {} show --kill",
WORKDIR_PREFIX,
shell_quote(cli_entry)
);
exec_oneshot(
container_id,
vec!["sh".to_string(), "-c".to_string(), script],
)
.await
container
.exec_oneshot(vec!["sh".to_string(), "-c".to_string(), script])
.await
}
/// Turn a failed start into something the user can act on.
@@ -604,13 +604,16 @@ fn explain_start_failure(err: &str, log: &str) -> String {
}
/// Tail of the viewer's own output, for a start that didn't come up.
async fn read_viewer_log(container_id: &str) -> String {
exec_oneshot(
container_id,
vec!["tail".to_string(), "-n".to_string(), "40".to_string(), VIEWER_LOG.to_string()],
)
.await
.unwrap_or_default()
async fn read_viewer_log(container: &ContainerTarget) -> String {
container
.exec_oneshot(vec![
"tail".to_string(),
"-n".to_string(),
"40".to_string(),
VIEWER_LOG.to_string(),
])
.await
.unwrap_or_default()
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -645,14 +648,17 @@ const MAX_PORT_ATTEMPTS: usize = 3;
/// this layer, and the pane would then front it. What contains that is
/// downstream — the host proxy is loopback-only and token-gated, and the pane's
/// iframe is sandboxed — not this function.
async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, String), String> {
async fn start_viewer(
container: &ContainerTarget,
cli_entry: &str,
) -> Result<(u16, String), String> {
let mut tried: Vec<u16> = Vec::new();
let mut last: Option<String> = None;
for _ in 0..MAX_PORT_ATTEMPTS {
// Re-read the listener snapshot each attempt: the port that was free a
// moment ago is exactly the one we may have just lost.
let port = match pick_viewer_port(container_id, &tried).await {
let port = match pick_viewer_port(container, &tried).await {
Ok(p) => p,
Err(e) => {
// Report why the *attempts* failed, not just "nothing free":
@@ -666,21 +672,21 @@ async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, Strin
};
tried.push(port);
launch_viewer(container_id, cli_entry, port).await?;
launch_viewer(container, cli_entry, port).await?;
// Wait for it to actually answer, and learn the entry URL while we're
// there — see `probe_entry_path` for why that matters. This, not the
// launcher's stdout, is the readiness signal: verified that the
// "Listening on …" line is printed only on the very first start.
match wait_until_ready(container_id, port).await {
match wait_until_ready(container, port).await {
Ok(path) => return Ok((port, path)),
Err(e) => {
let log = read_viewer_log(container_id).await;
let log = read_viewer_log(container).await;
// Always kill before retrying: the dashboard is a singleton, so
// a launcher that came up on some *other* port would otherwise
// make every further attempt a no-op that silently ignores the
// port we asked for.
let _ = kill_dashboard(container_id, cli_entry).await;
let _ = kill_dashboard(container, cli_entry).await;
last = Some(explain_start_failure(&e, &log));
}
}
@@ -691,10 +697,9 @@ async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, Strin
/// First port in [`VIEWER_PORTS`] that nothing in the container is listening on
/// and that this start has not already tried.
async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result<u16, String> {
let text = exec_oneshot(
container_id,
vec![
async fn pick_viewer_port(container: &ContainerTarget, tried: &[u16]) -> Result<u16, String> {
let text = container
.exec_oneshot(vec![
// Absolute path, deliberately, for the same reason the auth bridge
// uses one: `container/Dockerfile` puts a container-writable
// directory first on `PATH`, so a bare `cat` is a name the container
@@ -704,10 +709,9 @@ async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result<u16, Stri
"/usr/bin/cat".to_string(),
"/proc/net/tcp".to_string(),
"/proc/net/tcp6".to_string(),
],
)
.await
.unwrap_or_default();
])
.await
.unwrap_or_default();
let taken = proc_net::parse_loopback_listeners(&text);
VIEWER_PORTS
.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.
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;
loop {
let last = match probe_entry_path(container_id, port).await {
let last = match probe_entry_path(container, port).await {
Ok(path) => return Ok(path),
Err(e) => e,
};
@@ -751,7 +755,7 @@ const PROBE_MARKER: &str = "__TRIPLE_C_BV_PATH__";
/// a redirect — which matters, because a redirect drops the `?token=` the proxy
/// gate wants and would leave a fresh connection to be authorised with nothing.
/// A `200` (no redirect) is fine too; then the entry point is just `/`.
async fn probe_entry_path(container_id: &str, port: u16) -> Result<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
// bad WebSocket path by holding the socket open forever rather than
// erroring, so "no reply" is a state this probe has to be able to leave —
@@ -766,10 +770,7 @@ async fn probe_entry_path(container_id: &str, port: u16) -> Result<String, Strin
);
let out = tokio::time::timeout(
PROBE_TIMEOUT * 2,
exec_oneshot(
container_id,
vec!["node".to_string(), "-e".to_string(), script],
),
container.exec_oneshot(vec!["node".to_string(), "-e".to_string(), script]),
)
.await
.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 crate::commands::project_commands::emit_progress;
use crate::docker::exec::exec_oneshot_as;
use crate::host::ContainerTarget;
use super::detect::PlaywrightDetection;
@@ -150,14 +150,14 @@ pub async fn open(
shell_quote(&script),
HELPER_LOG
);
exec_oneshot_as(
container_id,
"claude",
vec!["sh".to_string(), "-c".to_string(), launcher],
Vec::new(),
)
.await
.map_err(|e| format!("Could not start the browser helper: {}", e))?;
ContainerTarget::docker(container_id)
.exec_oneshot_as(
"claude",
vec!["sh".to_string(), "-c".to_string(), launcher],
Vec::new(),
)
.await
.map_err(|e| format!("Could not start the browser helper: {}", e))?;
emit_progress(app, project_id, "Waiting for the page to load…");
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){{}}",
STATE_PATH
);
let Ok((out, _)) = exec_oneshot_as(
container_id,
"claude",
vec!["node".to_string(), "-e".to_string(), script],
Vec::new(),
)
.await
let Ok((out, _)) = ContainerTarget::docker(container_id)
.exec_oneshot_as(
"claude",
vec!["node".to_string(), "-e".to_string(), script],
Vec::new(),
)
.await
else {
return PageState::default();
};
@@ -209,15 +209,15 @@ async fn write_control(container_id: &str, json: String) -> Result<(), String> {
"require('fs').writeFileSync('{}',process.argv[1]);",
CONTROL_PATH
);
exec_oneshot_as(
container_id,
"claude",
vec!["node".to_string(), "-e".to_string(), script, json],
Vec::new(),
)
.await
.map(|_| ())
.map_err(|e| format!("Could not reach the browser helper: {}", e))
ContainerTarget::docker(container_id)
.exec_oneshot_as(
"claude",
vec!["node".to_string(), "-e".to_string(), script, json],
Vec::new(),
)
.await
.map(|_| ())
.map_err(|e| format!("Could not reach the browser helper: {}", e))
}
/// Wait for a *running* helper to report the URL we just asked it for.
+20 -27
View File
@@ -1,6 +1,7 @@
use tauri::{AppHandle, Emitter, State};
use crate::commands::aws_commands;
use crate::host::ContainerTarget;
use crate::models::{Backend, BedrockAuthMethod, Project};
use crate::AppState;
@@ -159,9 +160,7 @@ pub async fn open_terminal_session(
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project
.container_id
.as_ref()
let target = ContainerTarget::for_project(&project)
.ok_or_else(|| "Container not running".to_string())?;
if let Some(id) = resume_session_id.as_deref() {
@@ -189,7 +188,7 @@ pub async fn open_terminal_session(
state
.exec_manager
.create_session(
&crate::host::ContainerTarget::docker(container_id.as_str()),
&target,
&session_id,
cmd,
move |data| {
@@ -240,7 +239,7 @@ pub async fn paste_image_to_terminal(
image_data: Vec<u8>,
state: State<'_, AppState>,
) -> Result<String, String> {
let container_id = state.exec_manager.get_container_id(&session_id).await?;
let target = ContainerTarget::docker(state.exec_manager.get_container_id(&session_id).await?);
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -250,11 +249,7 @@ pub async fn paste_image_to_terminal(
state
.exec_manager
.write_file_to_container(
&crate::host::ContainerTarget::docker(container_id.as_str()),
&file_name,
&image_data,
)
.write_file_to_container(&target, &file_name, &image_data)
.await
}
@@ -287,7 +282,7 @@ pub async fn upload_host_file_to_terminal(
let base = crate::commands::file_commands::host_upload_name(&host_path)?;
let host_path = crate::commands::file_commands::resolve_host_read_path(&host_path).await?;
let container_id = state.exec_manager.get_container_id(&session_id).await?;
let target = ContainerTarget::docker(state.exec_manager.get_container_id(&session_id).await?);
let meta = tokio::fs::metadata(&host_path)
.await
@@ -317,7 +312,7 @@ pub async fn upload_host_file_to_terminal(
// with the code that does the reading, which re-applies it to the open
// descriptor — this check is here only so the refusal reads like a sentence
// instead of arriving after a 300 MB read.
use crate::docker::exec::MAX_DROP_BYTES;
use crate::host::MAX_DROP_BYTES;
if meta.len() > MAX_DROP_BYTES {
return Err(format!(
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project instead.",
@@ -330,20 +325,18 @@ pub async fn upload_host_file_to_terminal(
// Ensure the destination directory exists rather than relying on Docker's
// archive extractor to create the parent for the uploaded tar entry.
crate::docker::exec::exec_oneshot(
&container_id,
vec!["mkdir".to_string(), "-p".to_string(), "/tmp/triple-c-drops".to_string()],
)
.await?;
target
.exec_oneshot(vec![
"mkdir".to_string(),
"-p".to_string(),
"/tmp/triple-c-drops".to_string(),
])
.await?;
let file_name = format!("triple-c-drops/{}", base);
crate::docker::exec::upload_host_file_to_container(
&container_id,
&host_path,
"/tmp",
&file_name,
)
.await
target
.upload_host_file(&host_path, "/tmp", &file_name)
.await
}
#[tauri::command]
@@ -351,8 +344,8 @@ pub async fn start_audio_bridge(
session_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
// Get container_id from the terminal session
let container_id = state.exec_manager.get_container_id(&session_id).await?;
// Get the container from the terminal session
let target = ContainerTarget::docker(state.exec_manager.get_container_id(&session_id).await?);
// Create audio bridge exec session with ID "audio-{session_id}"
// The loop handles reconnection when the FIFO reader (fake rec) is killed and restarted
@@ -366,7 +359,7 @@ pub async fn start_audio_bridge(
state
.exec_manager
.create_session_with_tty(
&crate::host::ContainerTarget::docker(container_id.as_str()),
&target,
&audio_session_id,
cmd,
false,