feat!: remove sandbox, tool sessions, Node download, QR, log tee
Nix / Nix Eval + Web Frontend (push) Successful in 1m27s
CI / Web (push) Failing after 4m43s
CI / Rust (push) Failing after 8m49s

Five feature removals, ~27.8k lines:

- Docker/Podman sandbox: src/containers/, container_config, acp::sandbox,
  SandboxInfo, the [sandbox] config section, container terminals, in-container
  hooks, volume_ignores, the sandbox-image banner (and [updates], which existed
  only to gate it), /api/docker/status, and docker/.
- ACP Node download: the pinned-tarball fetch and NodeSource::Bundled. Drops
  the tar and xz2 deps, and with xz2 the system liblzma requirement.
- session_tee: the per-session tracing mirror. Agent stderr still reaches
  acp-workers/<id>.log via the runner; daemon events stay in debug.log.
- qrcode: dep plus the serve dialog's QR block.
- session.tools: tool sessions, picker dialog, ViewMode::Tool, hotkeys.

Legacy tmux prefixes (aoe_tool_*, aoe_cterm_*) are kept as sweep-only
constants so sessions left by an older build are still torn down rather than
mistaken for agent panes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-22 14:05:26 +01:00
parent 6cd0898100
commit 2f50a83b01
132 changed files with 805 additions and 27863 deletions
Generated
+1 -117
View File
@@ -130,7 +130,6 @@ dependencies = [
"percent-encoding",
"portable-pty",
"pulldown-cmark",
"qrcode",
"rand 0.10.2",
"ratatui",
"ratatui-textarea",
@@ -148,7 +147,6 @@ dependencies = [
"sha2 0.11.0",
"shell-words",
"similar",
"tar",
"tempfile",
"thiserror 2.0.20",
"tokio",
@@ -169,7 +167,6 @@ dependencies = [
"vt100",
"webbrowser",
"which",
"xz2",
]
[[package]]
@@ -605,12 +602,6 @@ version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -1340,17 +1331,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "filetime"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
dependencies = [
"cfg-if",
"libc",
"libredox",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1987,18 +1967,6 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2284,10 +2252,7 @@ version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [
"bitflags 2.13.0",
"libc",
"plain",
"redox_syscall 0.7.5",
]
[[package]]
@@ -2370,17 +2335,6 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-sys"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "mac_address"
version = "1.1.8"
@@ -2477,16 +2431,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "ndk-context"
version = "0.1.1"
@@ -2791,7 +2735,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall 0.5.18",
"redox_syscall",
"smallvec",
"windows-link",
]
@@ -2969,12 +2913,6 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "polling"
version = "3.11.0"
@@ -3080,21 +3018,6 @@ dependencies = [
"unicase",
]
[[package]]
name = "pxfm"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
dependencies = [
"image",
]
[[package]]
name = "quinn"
version = "0.11.9"
@@ -3375,15 +3298,6 @@ dependencies = [
"bitflags 2.13.0",
]
[[package]]
name = "redox_syscall"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b"
dependencies = [
"bitflags 2.13.0",
]
[[package]]
name = "redox_users"
version = "0.5.2"
@@ -4282,17 +4196,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -5533,16 +5436,6 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "xtask"
version = "0.1.0"
@@ -5553,15 +5446,6 @@ dependencies = [
"notify",
]
[[package]]
name = "xz2"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
dependencies = [
"lzma-sys",
]
[[package]]
name = "yoke"
version = "0.8.2"
+1 -9
View File
@@ -147,7 +147,6 @@ webbrowser = { version = "1", optional = true }
tower-http = { version = "0.7", features = ["fs"], optional = true }
rust-embed = { version = "8", features = ["debug-embed"], optional = true }
mime_guess = { version = "2.0", optional = true }
qrcode = { version = "0.14", optional = true }
# Always-on: used by the TUI for OSC 52 clipboard writes (preview
# drag-select in live mode) and re-exported via the `serve` feature
@@ -167,13 +166,8 @@ agent-client-protocol = { version = "1.0", optional = true, features = ["unstabl
# Semver parsing for ACP adapter compatibility checks (see src/acp/agent_compat.rs).
semver = { version = "1", optional = true }
# Tar stream handling for the structured view's bundled-Node fallback (with
# xz2 for tar.xz); flate2 also backs the live-WS permessage-deflate codec. Both
# are pure Rust and unconditional; xz2 needs system liblzma and stays gated to
# the structured view (serve).
tar = "0.4"
# Raw-deflate codec behind the live-WS frame stream (src/server/live_ws.rs).
flate2 = "1"
xz2 = { version = "0.1", optional = true }
# WebSocket client for the structured view-in-TUI view: subscribes to the daemon's
# `/sessions/{id}/acp/ws` stream and pumps frames into the local reducer.
@@ -213,7 +207,6 @@ serve = [
"tower-http",
"rust-embed",
"mime_guess",
"qrcode",
"p256",
"hkdf",
"aes-gcm",
@@ -228,7 +221,6 @@ serve = [
# under src/acp/).
"agent-client-protocol",
"semver",
"xz2",
"tokio-tungstenite",
# Sandboxed, race-safe file reads for the session file endpoint (#3088):
# open beneath a capability Dir so a symlink or component swapped between
-123
View File
@@ -1,123 +0,0 @@
# aoe-sandbox: Docker image for Agent of Empires sandbox sessions
# This image provides Claude Code, OpenCode, Mistral Vibe, Hermes, Codex CLI, Gemini CLI, Antigravity CLI, Cursor CLI, Copilot CLI, Pi, OMP, Kiro CLI, and Qwen Code in an isolated environment
FROM ubuntu:26.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
curl \
git \
build-essential \
ca-certificates \
gnupg \
jq \
openssh-client \
ripgrep \
fzf \
unzip \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js (required for Codex CLI)
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Run as root - IS_SANDBOX=1 allows Claude Code to use --dangerously-skip-permissions
WORKDIR /root
# Install Claude Code CLI (official installer)
RUN curl -fsSL https://claude.ai/install.sh | bash
ENV PATH="/root/.local/bin:${PATH}"
# Install Oh My Pi (OMP) using its official prebuilt-binary installer
RUN curl -fsSL https://omp.sh/install | sh
# Install OpenCode
RUN curl -fsSL https://opencode.ai/install | bash
ENV PATH="/root/.opencode/bin:${PATH}"
# Install Hermes (NousResearch/hermes-agent)
# --skip-setup avoids the interactive wizard; the FHS-style root install
# places `hermes` at /usr/local/bin and leaves config under /root/.hermes.
RUN curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh \
| bash -s -- --skip-setup
# Install Codex CLI (OpenAI)
RUN npm install -g @openai/codex
# Install Gemini CLI (Google)
RUN npm install -g @google/gemini-cli
# Install Antigravity CLI (Google)
RUN curl -fsSL https://antigravity.google/cli/install.sh | bash
# Install Mistral Vibe
RUN curl -LsSf https://mistral.ai/vibe/install.sh | bash
ENV PATH="/root/.vibe/bin:${PATH}"
# Install Cursor CLI
RUN curl -fsSL https://cursor.com/install | bash
# Install Copilot CLI (GitHub)
RUN npm install -g @github/copilot
# Install Pi (pi.dev). The pi-acp adapter (installed below alongside other
# structured view ACP adapters) shells out to the `pi` binary that this package
# provides, so the two packages are paired.
RUN npm install -g @earendil-works/pi-coding-agent
# Install Kiro CLI (AWS)
RUN curl -fsSL https://cli.kiro.dev/install | bash
# Install Qwen Code (Alibaba / QwenLM)
RUN npm install -g @qwen-code/qwen-code
# Install Kimi Code (Moonshot AI). Ships its own native ACP server
# (`kimi acp`), so no separate adapter is needed below. Install to
# /usr/local (binary at /usr/local/bin/kimi) rather than the default
# ~/.kimi-code/bin: the aoe sandbox bind-mounts the host's ~/.kimi-code
# config over /root/.kimi-code, which would otherwise shadow the binary.
RUN curl -fsSL https://code.kimi.com/kimi-code/install.sh \
| KIMI_INSTALL_DIR=/usr/local KIMI_NO_MODIFY_PATH=1 bash
# Install ACP adapters used by `aoe`'s structured view mode when the session is
# sandboxed. The structured view runner `docker exec`s these by their bare binary
# name (see `src/acp/agent_registry.rs`); if they aren't present in
# the image the agent process exits with status 127 and the ACP handshake
# times out. The list mirrors the npm-distributed adapters in
# `src/acp/install_hints.rs`; native adapters (`opencode acp`,
# `gemini --acp`, `vibe-acp`) are already provided by their respective
# CLIs above.
RUN npm install -g \
@agentclientprotocol/claude-agent-acp@^0.55.0 \
@agentclientprotocol/codex-acp@latest \
pi-acp
# Create directories for credential mounts
RUN mkdir -p /root/.claude \
/root/.config/opencode \
/root/.local/share/opencode \
/root/.hermes \
/root/.vibe \
/root/.codex \
/root/.gemini \
/root/.gemini/antigravity-cli \
/root/.cursor \
/root/.copilot \
/root/.pi \
/root/.omp \
/root/.factory \
/root/.kiro \
/root/.qwen \
/root/.kimi-code \
/root/.ssh
# Allow Claude Code to use --dangerously-skip-permissions as root
ENV IS_SANDBOX=1
# Set working directory
WORKDIR /workspace
# Default command - keep container running for docker exec
CMD ["sleep", "infinity"]
-54
View File
@@ -1,54 +0,0 @@
# aoe-dev-sandbox: Extended sandbox with development tools
# Includes: Rust, uv (Python), nvm + Node.js, pnpm, bun, gh (GitHub CLI)
# Note: Claude Code, OpenCode, Mistral Vibe, Hermes, Codex CLI, Gemini CLI,
# Antigravity CLI, Cursor CLI, Copilot CLI, Pi, OMP, Kiro CLI, Qwen Code, and Kimi Code are inherited from the base image
ARG BASE_IMAGE=ghcr.io/agent-of-empires/aoe-sandbox:latest
FROM ${BASE_IMAGE}
USER root
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
| tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& apt-get update \
&& apt-get install -y gh tmux \
&& rm -rf /var/lib/apt/lists/*
# Continue as root (base image sets IS_SANDBOX=1)
WORKDIR /root
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:${PATH}"
ENV NVM_DIR="/root/.nvm"
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash \
&& . "$NVM_DIR/nvm.sh" \
&& nvm install --lts \
&& nvm alias default node \
&& npm install -g pnpm \
&& ln -s "$(which node)" /root/.local/bin/node \
&& ln -s "$(which npm)" /root/.local/bin/npm \
&& ln -s "$(which npx)" /root/.local/bin/npx \
&& ln -s "$(which pnpm)" /root/.local/bin/pnpm
# Install Bun (requires unzip)
RUN apt-get update && apt-get install -y unzip && rm -rf /var/lib/apt/lists/* \
&& curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:${PATH}"
# Add dev tool paths to profile.d so they're available in all shells (login and non-login)
RUN echo 'export PATH="/root/.bun/bin:/root/.cargo/bin:/root/.local/bin:$PATH"' > /etc/profile.d/dev-tools.sh \
&& echo 'export NVM_DIR="/root/.nvm"' >> /etc/profile.d/dev-tools.sh \
&& echo '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"' >> /etc/profile.d/dev-tools.sh \
&& chmod +x /etc/profile.d/dev-tools.sh
# Also set BASH_ENV so non-interactive bash shells source the paths
ENV BASH_ENV=/etc/profile.d/dev-tools.sh
WORKDIR /workspace
+2 -6
View File
@@ -32,10 +32,7 @@
# Cargo source filtering keeps only *.rs, *.toml and Cargo.lock, so
# every other compile-time embedded asset has to be unioned in
# explicitly: the acp-worker/adapters manifests that
# src/acp/adapters.rs reads with include_bytes! (#3204), and
# docker/Dockerfile, which the agent_compat test embeds to pin the
# sandbox npm floor (the aoe-test and aoe-clippy checks compile test
# code, so they need it even though the packages do not).
# src/acp/adapters.rs reads with include_bytes! (#3204).
# `scripts/check-nix-embedded-assets.py` fails CI if a new embedded
# asset lands without being added here.
src = pkgs.lib.fileset.toSource {
@@ -43,7 +40,6 @@
fileset = pkgs.lib.fileset.unions [
(craneLib.fileset.commonCargoSources ./.)
./acp-worker/adapters
./docker
];
};
strictDeps = true;
@@ -70,7 +66,7 @@
Agent of Empires (AoE) is a terminal session manager for AI coding
agents on Linux and macOS. Built on tmux, it allows running multiple
AI agents in parallel across different branches of your codebase,
each in its own isolated session with optional Docker sandboxing.
each in its own isolated session.
Supports Claude Code, OpenCode, Mistral Vibe, Codex CLI, and Gemini CLI.
'';
-2
View File
@@ -268,8 +268,6 @@ def self_test():
("src/lib.rs", [], True),
("themes/builtin/zinc.toml", [], True),
("Cargo.lock", [], True),
("docker/Dockerfile", [], False),
("docker/Dockerfile", ["docker"], True),
("acp-worker/adapters/pi-acp/package.json", [], False),
("acp-worker/adapters/pi-acp/package.json", ["acp-worker/adapters"], True),
# A prefix match must respect path boundaries.
+34 -974
View File
File diff suppressed because it is too large Load Diff
+12 -53
View File
@@ -1,9 +1,8 @@
//! On-demand install and resolution of the npm-distributed ACP adapters
//! that aoe pins (`claude-agent-acp`, `codex-acp`, `pi-acp`).
//!
//! Mirrors the bundled-Node pattern in [`crate::acp::node`]: a pinned
//! manifest is embedded in the binary and installed into the data dir by
//! `aoe acp doctor --fix` using the resolved Node's own npm, instead of
//! A pinned manifest is embedded in the binary and installed into the data
//! dir by `aoe acp doctor --fix` using the resolved Node's npm, instead of
//! `npm install -g` (no global prefix, no sudo, a version aoe controls).
//! See issue #1017.
//!
@@ -22,7 +21,7 @@ use std::path::{Path, PathBuf};
use thiserror::Error;
use tracing::{info, warn};
use crate::acp::node::{NodeSource, ResolvedNode};
use crate::acp::node::ResolvedNode;
/// One pinned adapter: the binary npm installs, plus its embedded manifest
/// pair. Kept in lockstep with `acp-worker/adapters/<binary>/`.
@@ -214,37 +213,19 @@ pub fn install(app_dir: &Path, node: &ResolvedNode, binary: &str) -> Result<(),
Ok(())
}
/// Build the argv for `npm ci`, run from the target dir. For a bundled
/// Node, invoke its own `npm-cli.js` with that exact node (the official
/// tarball ships it at `<root>/lib/node_modules/npm/bin/npm-cli.js`); for a
/// host Node, use `npm` on PATH, because a host Node's npm layout is not
/// something we can assume. `None` when no usable npm is found.
pub fn npm_ci_argv(node: &ResolvedNode) -> Option<(PathBuf, Vec<String>)> {
let ci_flags = || {
/// Build the argv for `npm ci`, run from the target dir. aoe never installs
/// its own Node, so this always drives `npm` from PATH. `None` when no
/// usable npm is found.
pub fn npm_ci_argv(_node: &ResolvedNode) -> Option<(PathBuf, Vec<String>)> {
let npm = which::which("npm").ok()?;
Some((
npm,
vec![
"ci".to_string(),
"--no-audit".to_string(),
"--no-fund".to_string(),
]
};
if matches!(node.source, NodeSource::Bundled) {
// `<root>/bin/node` -> `<root>`.
if let Some(root) = node.path.parent().and_then(|p| p.parent()) {
let npm_cli = root
.join("lib")
.join("node_modules")
.join("npm")
.join("bin")
.join("npm-cli.js");
if npm_cli.is_file() {
let mut args = vec![npm_cli.to_string_lossy().into_owned()];
args.extend(ci_flags());
return Some((node.path.clone(), args));
}
}
}
let npm = which::which("npm").ok()?;
Some((npm, ci_flags()))
],
))
}
/// Move a completed staging dir into place. `rename` cannot replace a
@@ -401,28 +382,6 @@ mod tests {
assert!(!installation_is_current(app, "not-an-adapter"));
}
#[test]
fn npm_ci_argv_uses_bundled_npm_cli_when_present() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("node-v22.21.0");
let node_bin = root.join("bin").join("node");
std::fs::create_dir_all(node_bin.parent().unwrap()).unwrap();
std::fs::write(&node_bin, b"node").unwrap();
let npm_cli = root.join("lib/node_modules/npm/bin/npm-cli.js");
std::fs::create_dir_all(npm_cli.parent().unwrap()).unwrap();
std::fs::write(&npm_cli, b"npm").unwrap();
let node = ResolvedNode {
path: node_bin.clone(),
version: "v22.21.0".to_string(),
source: NodeSource::Bundled,
};
let (program, args) = npm_ci_argv(&node).unwrap();
assert_eq!(program, node_bin);
assert_eq!(args[0], npm_cli.to_string_lossy());
assert_eq!(&args[1..], &["ci", "--no-audit", "--no-fund"]);
}
/// `publish` is the riskiest code here: fresh install, replacing an
/// existing one, and rollback when the swap fails.
#[test]
+3 -31
View File
@@ -35,13 +35,9 @@ use super::state::StartupErrorDetail;
/// Single source of truth for the `claude-agent-acp` minimum-version floor.
///
/// Bumping the floor is a one-line edit here: the gate, the startup-error
/// strings, and the boundary tests all derive from this value. The one
/// peer that cannot read a Rust const, the npm pin in `docker/Dockerfile`,
/// is held in sync by the `dockerfile_pin_matches_floor` test below, so a
/// bump that forgets the Dockerfile fails CI rather than shipping a
/// sandbox image stuck below the host floor. User docs deliberately do not
/// restate the number; the startup-error path reports the exact floor
/// dynamically at rejection time.
/// strings, and the boundary tests all derive from this value. User docs
/// deliberately do not restate the number; the startup-error path reports
/// the exact floor dynamically at rejection time.
pub const CLAUDE_AGENT_ACP_MIN_VERSION: &str = "0.55.0";
/// Parsed form of [`CLAUDE_AGENT_ACP_MIN_VERSION`]. Runs once per adapter
@@ -632,30 +628,6 @@ mod tests {
validate(ExpectedAgent::ClaudeAgentAcp, &init).unwrap();
}
#[test]
fn dockerfile_pin_matches_floor() {
// docker/Dockerfile cannot read a Rust const, so the sandbox npm
// pin is the one floor restatement outside this module. Assert it
// tracks the gate so a bump that forgets the Dockerfile fails CI
// instead of shipping an image stuck below the host floor.
let dockerfile = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docker/Dockerfile"));
let needle = "@agentclientprotocol/claude-agent-acp@^";
let pins: Vec<String> = dockerfile
.match_indices(needle)
.map(|(idx, _)| {
dockerfile[idx + needle.len()..]
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect()
})
.collect();
assert_eq!(
pins,
vec![CLAUDE_AGENT_ACP_MIN_VERSION.to_string()],
"docker/Dockerfile claude-agent-acp pin must match CLAUDE_AGENT_ACP_MIN_VERSION",
);
}
/// Steering needs the advertised bit AND, for claude, the
/// `promptRequired` floor. The table is the whole contract: an
/// advertised-but-old adapter is the case that matters, since 0.61
-1
View File
@@ -79,7 +79,6 @@ pub async fn probe_agent(agent: &str) -> anyhow::Result<bool> {
socket_path: None,
stored_acp_session_id: None,
fork_from: None,
sandbox_info: None,
source_profile: None,
seed_history_replay: false,
artifact_dir: None,
-2
View File
@@ -39,9 +39,7 @@ pub mod option_catalog;
pub mod permissions;
pub mod protocol;
#[cfg(feature = "serve")]
pub mod sandbox;
pub mod session_paths;
pub mod session_tee;
pub mod state;
pub mod supervisor;
pub mod terminal_handler;
+5 -254
View File
@@ -1,31 +1,23 @@
//! Node.js runtime resolution for acp-worker subprocesses.
//!
//! Resolve order (matches the v4 design doc):
//! Resolve order:
//! 1. `AOE_ACP_NODE` env var.
//! 2. `acp.node_path` from settings.
//! 3. `node` on `PATH` (must satisfy minimum version).
//! 4. Previously-downloaded Node at
//! `$AOE_DATA_DIR/acp/node-v22.21.0/bin/node`.
//! 5. (Future) download from nodejs.org/dist on first use.
//!
//! For 5 we have a real `download` function, but it is opt-in: the
//! caller must explicitly invoke it. Resolving at session-spawn time
//! aoe never installs Node itself. Resolving at session-spawn time
//! returns a typed error if no Node is present, and the CLI surfaces
//! the doctor's `[!! ] Node runtime missing` message.
use std::path::{Path, PathBuf};
use thiserror::Error;
use tracing::{debug, info, warn};
use tracing::debug;
/// The minimum Node major version aoe-agent supports. Matches the
/// `engines.node` field in `acp-worker/aoe-agent/package.json`.
pub const MIN_NODE_MAJOR: u32 = 20;
/// The pinned Node version aoe downloads when no host Node is found.
/// Bumping this requires bumping the SHA-256 below at the same time.
pub const PINNED_NODE_VERSION: &str = "22.21.0";
#[derive(Debug, Error)]
pub enum NodeError {
#[error("no Node.js >= {0} found and AOE_ACP_NODE is unset")]
@@ -53,13 +45,11 @@ pub enum NodeSource {
Env,
Settings,
Path,
Bundled,
}
/// Resolve Node.js for structured view use. `settings_node_path` is the value
/// configured in `acp.node_path` (empty when unset). `app_dir` is
/// where the bundled tarball would be extracted.
pub fn resolve(settings_node_path: &str, app_dir: &Path) -> Result<ResolvedNode, NodeError> {
/// configured in `acp.node_path` (empty when unset).
pub fn resolve(settings_node_path: &str) -> Result<ResolvedNode, NodeError> {
if let Ok(env_path) = std::env::var("AOE_ACP_NODE") {
if !env_path.is_empty() {
let path = PathBuf::from(env_path);
@@ -78,11 +68,6 @@ pub fn resolve(settings_node_path: &str, app_dir: &Path) -> Result<ResolvedNode,
}
}
let bundled = bundled_node_path(app_dir);
if bundled.exists() {
return verify_path(&bundled, NodeSource::Bundled);
}
Err(NodeError::NoNode(MIN_NODE_MAJOR))
}
@@ -133,169 +118,6 @@ fn which(binary: &str) -> Option<PathBuf> {
None
}
pub fn bundled_node_path(app_dir: &Path) -> PathBuf {
app_dir
.join("acp")
.join(format!("node-v{PINNED_NODE_VERSION}"))
.join("bin")
.join("node")
}
/// Pinned platform-specific tarball SHA-256 values for
/// `PINNED_NODE_VERSION`. Fetched once from nodejs.org's SHASUMS256.txt
/// and committed here. Bumping `PINNED_NODE_VERSION` requires
/// refreshing every entry in this table.
struct PlatformTarball {
/// e.g., "linux-x64". Forms the filename: node-vX.Y.Z-{slug}.tar.xz
slug: &'static str,
/// Hex-encoded SHA-256 of the tarball.
sha256: &'static str,
}
const PINNED_TARBALLS: &[(NodePlatform, PlatformTarball)] = &[
(
NodePlatform::LinuxX64,
PlatformTarball {
slug: "linux-x64",
sha256: "71a04f4b9144870c9407b8019fe912514229e50246bc706862eded3ac8e9025d",
},
),
(
NodePlatform::LinuxArm64,
PlatformTarball {
slug: "linux-arm64",
sha256: "fe3e371f6f72d07a3f75a94a54c97d652ace6bfcc48f82cc0867f0c0722b84bd",
},
),
(
NodePlatform::DarwinX64,
PlatformTarball {
slug: "darwin-x64",
sha256: "8c61b1ab7b3a398717b3503fbd205d239079cac22402ee9327f4d3a240622d86",
},
),
(
NodePlatform::DarwinArm64,
PlatformTarball {
slug: "darwin-arm64",
sha256: "54b884588727c9833cad6e4b902f922128b8da136ba845e76e878b0d2d08c8f4",
},
),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NodePlatform {
LinuxX64,
LinuxArm64,
DarwinX64,
DarwinArm64,
/// Windows uses a .zip; we don't support it via auto-download
/// today (would need a zip extractor). Users on Windows must
/// install Node themselves.
WindowsUnsupported,
}
pub fn detect_platform() -> NodePlatform {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
match (os, arch) {
("linux", "x86_64") => NodePlatform::LinuxX64,
("linux", "aarch64") => NodePlatform::LinuxArm64,
("macos", "x86_64") => NodePlatform::DarwinX64,
("macos", "aarch64") => NodePlatform::DarwinArm64,
("windows", _) => NodePlatform::WindowsUnsupported,
_ => NodePlatform::WindowsUnsupported,
}
}
fn pinned_for(platform: NodePlatform) -> Option<&'static PlatformTarball> {
PINNED_TARBALLS
.iter()
.find(|(p, _)| *p == platform)
.map(|(_, t)| t)
}
/// Download the pinned Node tarball from nodejs.org/dist and extract
/// to the bundled location. Verifies SHA-256 against the embedded
/// value before extracting.
///
/// On Windows, returns NoNode because tarball auto-download is not
/// implemented for .zip; users must install Node themselves.
pub async fn download(app_dir: &Path) -> Result<ResolvedNode, NodeError> {
let platform = detect_platform();
let tarball = pinned_for(platform).ok_or_else(|| {
warn!(
target: "acp.node",
"automated Node download not supported on this platform; install Node {} on PATH or set AOE_ACP_NODE",
MIN_NODE_MAJOR
);
NodeError::NoNode(MIN_NODE_MAJOR)
})?;
let url = format!(
"https://nodejs.org/dist/v{version}/node-v{version}-{slug}.tar.xz",
version = PINNED_NODE_VERSION,
slug = tarball.slug,
);
info!(target: "acp.node", url = %url, "downloading Node runtime");
let bytes = reqwest::Client::new()
.get(&url)
.send()
.await
.map_err(|e| NodeError::Io(std::io::Error::other(format!("fetch: {e}"))))?
.error_for_status()
.map_err(|e| NodeError::Io(std::io::Error::other(format!("status: {e}"))))?
.bytes()
.await
.map_err(|e| NodeError::Io(std::io::Error::other(format!("body: {e}"))))?;
let actual = sha256_hex(&bytes);
if !actual.eq_ignore_ascii_case(tarball.sha256) {
return Err(NodeError::Io(std::io::Error::other(format!(
"Node tarball SHA-256 mismatch: expected {} got {}",
tarball.sha256, actual
))));
}
info!(target: "acp.node", "downloaded {} bytes; SHA-256 verified", bytes.len());
// Extract under app_dir/acp/. The tarball's top-level dir is
// `node-vX.Y.Z-{slug}` so we extract into the parent and then
// rename/symlink to `node-vX.Y.Z` for a stable bundled-path lookup.
let acp_dir = app_dir.join("acp");
std::fs::create_dir_all(&acp_dir)?;
let cursor = std::io::Cursor::new(bytes);
let xz_decoder = xz2::read::XzDecoder::new(cursor);
let mut archive = tar::Archive::new(xz_decoder);
archive.unpack(&acp_dir)?;
// Move/rename the extracted dir to the stable name.
let extracted = acp_dir.join(format!("node-v{}-{}", PINNED_NODE_VERSION, tarball.slug));
let stable = acp_dir.join(format!("node-v{}", PINNED_NODE_VERSION));
if stable.exists() {
std::fs::remove_dir_all(&stable)?;
}
std::fs::rename(&extracted, &stable)?;
let bundled = bundled_node_path(app_dir);
verify_path(&bundled, NodeSource::Bundled)
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(bytes);
let digest = hasher.finalize();
let mut out = String::with_capacity(digest.len() * 2);
const HEX: &[u8; 16] = b"0123456789abcdef";
for b in digest {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0xF) as usize] as char);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
@@ -307,75 +129,4 @@ mod tests {
assert_eq!(parse_major("18.17.1"), Some(18));
assert_eq!(parse_major("not a version"), None);
}
#[test]
fn sha256_hex_matches_known_vector() {
// SHA-256 of the empty string per RFC 6234 / Wikipedia.
let hex = sha256_hex(b"");
assert_eq!(
hex,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn pinned_tarballs_cover_all_supported_platforms() {
for platform in [
NodePlatform::LinuxX64,
NodePlatform::LinuxArm64,
NodePlatform::DarwinX64,
NodePlatform::DarwinArm64,
] {
let tarball = pinned_for(platform);
assert!(tarball.is_some(), "missing pinned SHA for {platform:?}");
let sha = tarball.unwrap().sha256;
assert_eq!(sha.len(), 64, "SHA must be 64 hex chars");
assert!(
sha.chars().all(|c| c.is_ascii_hexdigit()),
"SHA must be hex"
);
}
assert!(pinned_for(NodePlatform::WindowsUnsupported).is_none());
}
#[test]
fn bundled_path_uses_pinned_version() {
let p = bundled_node_path(Path::new("/tmp/aoe"));
let s = p.to_string_lossy();
assert!(s.contains(&format!("node-v{PINNED_NODE_VERSION}")));
assert!(s.ends_with("/bin/node") || s.ends_with("\\bin\\node"));
}
#[test]
#[serial_test::serial]
fn resolve_uses_env_var_when_set() {
let Some(p) = which("node") else {
eprintln!("skipping: node not on PATH");
return;
};
std::env::set_var("AOE_ACP_NODE", &p);
let temp = tempfile::tempdir().unwrap();
let resolved = resolve("", temp.path()).expect("env var resolves");
std::env::remove_var("AOE_ACP_NODE");
assert!(matches!(resolved.source, NodeSource::Env));
}
#[test]
#[serial_test::serial]
fn resolve_returns_no_node_with_unmatchable_settings() {
// No PATH-side node, no env, no settings → NoNode.
let temp = tempfile::tempdir().unwrap();
let saved_path = std::env::var_os("PATH");
let saved_env = std::env::var_os("AOE_ACP_NODE");
std::env::remove_var("PATH");
std::env::remove_var("AOE_ACP_NODE");
let result = resolve("", temp.path());
if let Some(p) = saved_path {
std::env::set_var("PATH", p);
}
if let Some(v) = saved_env {
std::env::set_var("AOE_ACP_NODE", v);
}
assert!(matches!(result, Err(NodeError::NoNode(_))));
}
}
-156
View File
@@ -1,156 +0,0 @@
//! Sandbox container lifecycle for structured view sessions.
//!
//! The structured view spawn path mirrors the tmux path's sandbox handling: if
//! the session's `Instance` has `sandbox_info`, create + start the
//! Docker container, optionally run on_launch hooks inside it, and
//! hand the `SandboxInfo` back to the caller so the supervisor can
//! wrap the agent argv in `docker exec`.
//!
//! This lives in its own module so every structured view-spawn entry point
//! (auto-spawn after create, view switch, manual `POST /spawn`,
//! reconciler reattach fallback) goes through the same code path.
use std::sync::Arc;
use anyhow::{Context, Result};
use tokio::sync::{Mutex, RwLock};
use crate::session::{Instance, SandboxInfo};
/// Ensure the sandbox container for the named session is running.
///
/// `run_on_launch_hooks` controls whether profile/repo `on_launch`
/// hooks fire after the container is up. Only the initial
/// `auto-spawn after create` path passes `true`; every other entry
/// point (view switch, manual `/spawn`, reconciler resume)
/// passes `false` so hooks don't re-run on every reattach.
///
/// `instance_lock` is the per-session `state.instance_lock(id)`
/// mutex. We hold it for the duration of the call so a concurrent
/// `delete_session` (which acquires the same mutex) can't tear down
/// the instance mid-create and leave us with an orphan docker
/// container. Docker work runs on a blocking thread; the in-memory
/// `Instance` is otherwise locked only for a short read snapshot
/// and a short write to stamp the resolved `container_id`.
///
/// Returns the session's `sandbox_info` (with `container_id`
/// populated); for non-sandboxed sessions it returns `None`.
pub async fn ensure_container_for_session(
instances: &RwLock<Vec<Instance>>,
instance_lock: &Arc<Mutex<()>>,
session_id: &str,
run_on_launch_hooks: bool,
) -> Result<Option<SandboxInfo>> {
// Hold the per-session mutex for the whole call so delete_session
// (which acquires the same lock) cannot interleave between the
// existence check and the container create.
let _guard = instance_lock.lock().await;
ensure_container_for_session_locked(instances, session_id, run_on_launch_hooks).await
}
/// Ensure the sandbox container while the caller already holds the
/// per-session instance mutex.
pub async fn ensure_container_for_session_locked(
instances: &RwLock<Vec<Instance>>,
session_id: &str,
run_on_launch_hooks: bool,
) -> Result<Option<SandboxInfo>> {
// Phase 1: short read on the instance list. Clone the Instance so
// the docker work can run on a blocking thread without holding
// any tokio lock.
let mut instance_clone = {
let guard = instances.read().await;
let Some(inst) = guard.iter().find(|i| i.id == session_id) else {
anyhow::bail!("session {session_id} not found");
};
if !inst.is_sandboxed() {
return Ok(None);
}
inst.clone()
};
// Phase 2: docker create/start + workdir/hook resolution on a
// blocking thread. `get_container_for_instance` may pull an image
// and create the container, both of which can take many seconds;
// running it here keeps the tokio worker free for other requests.
let session_id_owned = session_id.to_string();
let (sandbox_info, container_workdir, hooks, hook_env) =
tokio::task::spawn_blocking(move || -> Result<_> {
let _container = instance_clone
.get_container_for_instance()
.context("ensuring sandbox container")?;
let workdir = instance_clone.container_workdir();
let hooks = if run_on_launch_hooks {
let profile = instance_clone.source_profile.clone();
instance_clone.resolve_on_launch_hooks(false, &profile)
} else {
None
};
let env = crate::session::repo_config::lifecycle_env_vars(&instance_clone);
Ok((instance_clone.sandbox_info.clone(), workdir, hooks, env))
})
.await
.context("docker ensure task failed to join")??;
// Phase 3: brief write lock to stamp the resolved container_id (and the
// host-minted before_start env) back onto the live Instance so subsequent
// reads observe them. Carrying `before_start_env` back matters because the
// mint ran on the cloned instance above; without this, the next
// ensure_container_for_session would clone an empty cache and re-run the
// before_start hook on every view switch / spawn, re-minting credentials.
if let Some(info) = &sandbox_info {
let mut guard = instances.write().await;
if let Some(inst) = guard.iter_mut().find(|i| i.id == session_id_owned) {
if let Some(ref mut sb) = inst.sandbox_info {
if sb.container_id.is_none() {
sb.container_id = info.container_id.clone();
}
sb.before_start_env = info.before_start_env.clone();
}
}
}
// Phase 4: run on_launch hooks outside the instances lock (the
// per-session mutex is still held for the call's lifetime). Hooks
// are shell commands that can themselves take seconds, so wrap in
// spawn_blocking. Best-effort: failures are logged and the spawn
// proceeds.
if let (Some(cmds), Some(info)) = (hooks, sandbox_info.as_ref()) {
if !cmds.is_empty() {
let container_name = info.container_name.clone();
let workdir = container_workdir.clone();
let sid = session_id.to_string();
match tokio::task::spawn_blocking(move || {
crate::session::repo_config::execute_hooks_in_container_best_effort(
&cmds,
&container_name,
&workdir,
true,
&hook_env,
)
})
.await
{
Ok(errors) => {
for err in errors {
tracing::warn!(
target: "acp.sandbox",
session = %sid,
"on_launch hook failed: {err}"
);
}
}
Err(e) => {
tracing::warn!(
target: "acp.sandbox",
session = %sid,
"on_launch hook task failed to join: {e}"
);
}
}
}
}
Ok(sandbox_info)
}
-410
View File
@@ -1,410 +0,0 @@
//! Per-session tracing tee: mirrors session-scoped events into each
//! session's `acp-workers/<id>.log` so `aoe acp logs --session <id>`
//! surfaces the daemon's watchdog/cancel breadcrumbs, not just the
//! startup marker plus agent stderr. Additive: events still flow to the
//! shared `debug.log`. See issue #1864.
//!
//! Capture. Events are routed by their `session` (or rare `session_id`)
//! field. A `tracing::info_span!("acp_session", session = %id)` wraps
//! each daemon per-session connection task, so events that do not set the
//! field explicitly still inherit it through the span scope. The layer
//! reads the event's own fields first, then walks the span scope.
//!
//! I/O. Synchronous best-effort writes through a `SizeRotatingWriter` per
//! session, mirroring the shared `debug.log` writer (same rare-rotation
//! stall profile, so no new failure class). Writers are bounded by count
//! and the least-recently-used one is evicted, so a long-lived daemon
//! does not leak file handles. No background thread, no channel: dropping
//! a breadcrumb during a spike would lose exactly the diagnostics this
//! exists to capture.
//!
//! Re-entrancy. The writer never emits tracing; events with target
//! `acp.tee` are skipped so any future self-reporting cannot loop.
use std::collections::HashMap;
use std::io::Write;
use std::sync::{Arc, Mutex, MutexGuard};
use tracing::field::{Field, Visit};
use tracing::span::Attributes;
use tracing::{Event, Id, Subscriber};
use tracing_subscriber::layer::Context;
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::Layer;
use crate::logging::{RotationPolicy, SizeRotatingWriter};
use crate::session::config::RotationKind;
// The layer is installed generically (un-boxed) in the daemon subscriber
// stack so its `Layer<S> for any S` impl resolves against the full layered
// subscriber type, which a `Box<dyn Layer<Registry>>` could not name.
/// Span name carrying the session id for scope-based capture. Entered at
/// the daemon per-session connection task so nested events inherit the
/// `session` field even when they do not set it explicitly.
pub const SESSION_SPAN: &str = "acp_session";
/// Target reserved for the tee's own diagnostics; skipped on the event
/// path to prevent re-entrancy.
const TEE_TARGET: &str = "acp.tee";
/// Cap on simultaneously-open per-session log files. Realistic concurrent
/// session counts sit well under this; the bound only matters for a
/// daemon that churns through many sessions over days.
const MAX_OPEN_SESSION_LOGS: usize = 64;
const PER_SESSION_MAX_BYTES: u64 = 10 * 1024 * 1024;
const PER_SESSION_KEEP: u8 = 2;
/// Cached session id stored in a span's extensions on creation, so the
/// per-event scope walk is a pointer chase rather than a field re-visit.
struct SessionTag(String);
pub struct SessionTeeLayer {
writers: Mutex<WriterCache>,
}
struct WriterCache {
map: HashMap<String, Entry>,
/// Monotonic counter stamping each access; the smallest stamp is the
/// least-recently-used entry to evict when the cap is reached.
tick: u64,
}
struct Entry {
writer: Arc<Mutex<SizeRotatingWriter>>,
last_used: u64,
}
impl Default for SessionTeeLayer {
fn default() -> Self {
Self::new()
}
}
impl SessionTeeLayer {
pub fn new() -> Self {
Self {
writers: Mutex::new(WriterCache {
map: HashMap::new(),
tick: 0,
}),
}
}
/// Resolve (or open) the per-session writer, updating LRU bookkeeping.
/// Returns `None` when the session id is unsafe or the file cannot be
/// opened; the caller silently drops the line in that case.
fn writer_for(&self, session: &str) -> Option<Arc<Mutex<SizeRotatingWriter>>> {
let mut cache = lock(&self.writers);
cache.tick += 1;
let now = cache.tick;
if let Some(entry) = cache.map.get_mut(session) {
entry.last_used = now;
return Some(entry.writer.clone());
}
let path = crate::process::worker_registry::log_path_for(session).ok()?;
// At capacity: evict the oldest entry whose writer is idle
// (`strong_count == 1`, only the cache holds it). Evicting a writer
// still in flight on another thread would let this call open a
// second `SizeRotatingWriter` on the same file and race its appends
// and rotation. If every cached writer is in flight, drop this event
// rather than open a racing writer.
if cache.map.len() >= MAX_OPEN_SESSION_LOGS {
let evict = cache
.map
.iter()
.filter(|(_, e)| Arc::strong_count(&e.writer) == 1)
.min_by_key(|(_, e)| e.last_used)
.map(|(k, _)| k.clone())?;
cache.map.remove(&evict);
}
let policy = RotationPolicy {
kind: RotationKind::Size,
max_size_bytes: PER_SESSION_MAX_BYTES,
keep_count: PER_SESSION_KEEP,
};
let writer = Arc::new(Mutex::new(SizeRotatingWriter::new(path, policy).ok()?));
cache.map.insert(
session.to_string(),
Entry {
writer: writer.clone(),
last_used: now,
},
);
Some(writer)
}
}
impl<S> Layer<S> for SessionTeeLayer
where
S: Subscriber + for<'a> LookupSpan<'a>,
{
fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
if attrs.metadata().name() != SESSION_SPAN {
return;
}
let mut v = SessionVisitor::default();
attrs.record(&mut v);
if let Some(session) = v.session {
if let Some(span) = ctx.span(id) {
span.extensions_mut().insert(SessionTag(session));
}
}
}
fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) {
if event.metadata().target() == TEE_TARGET {
return;
}
// Collect the renderable line and any explicit session field in a
// single pass, then fall back to span-scope inheritance.
let mut visitor = LineVisitor::default();
event.record(&mut visitor);
let session = match visitor.session.clone() {
Some(s) => s,
None => match session_from_scope(event, &ctx) {
Some(s) => s,
None => return,
},
};
let Some(writer) = self.writer_for(&session) else {
return;
};
let line = visitor.format(event);
let mut w = lock(&writer);
let _ = w.write_all(line.as_bytes());
let _ = w.flush();
}
}
fn session_from_scope<S>(event: &Event<'_>, ctx: &Context<'_, S>) -> Option<String>
where
S: Subscriber + for<'a> LookupSpan<'a>,
{
let scope = ctx.event_scope(event)?;
for span in scope.from_root() {
if let Some(tag) = span.extensions().get::<SessionTag>() {
return Some(tag.0.clone());
}
}
None
}
/// Lock that never panics on poison: a writer panic must not propagate
/// out of the tracing event path. Recovers the inner guard instead.
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
/// Debug-formatted values arrive quoted (`"abc"`); strip surrounding
/// quotes so `session` values pass `log_path_for` validation and the
/// rendered line reads cleanly. Mirrors `StageRecorder` in `deletion.rs`.
fn unquote(s: &str) -> String {
s.trim_matches('"').to_string()
}
#[derive(Default)]
struct SessionVisitor {
session: Option<String>,
}
impl SessionVisitor {
fn capture(&mut self, field: &Field, value: String) {
match field.name() {
"session" => self.session = Some(value),
"session_id" if self.session.is_none() => self.session = Some(value),
_ => {}
}
}
}
impl Visit for SessionVisitor {
fn record_str(&mut self, field: &Field, value: &str) {
self.capture(field, value.to_string());
}
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
self.capture(field, unquote(&format!("{value:?}")));
}
}
/// Visitor that both finds the session id and collects the renderable
/// message plus remaining fields for the per-session line. The session
/// field itself is not echoed into the line: the file is already
/// per-session, so repeating it would be noise.
#[derive(Default)]
struct LineVisitor {
session: Option<String>,
message: Option<String>,
kv: String,
}
impl LineVisitor {
fn push(&mut self, field: &Field, value: String) {
match field.name() {
"message" => self.message = Some(value),
"session" => self.session = Some(value),
"session_id" => {
if self.session.is_none() {
self.session = Some(value);
}
}
name => {
self.kv.push(' ');
self.kv.push_str(name);
self.kv.push('=');
self.kv.push_str(&value);
}
}
}
fn format(&self, event: &Event<'_>) -> String {
let meta = event.metadata();
let ts = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
let msg = self.message.as_deref().unwrap_or("");
format!(
"{ts} {} {}: {}{}\n",
meta.level(),
meta.target(),
msg,
self.kv
)
}
}
impl Visit for LineVisitor {
fn record_str(&mut self, field: &Field, value: &str) {
self.push(field, value.to_string());
}
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
self.push(field, unquote(&format!("{value:?}")));
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use tempfile::TempDir;
use tracing::subscriber::with_default;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::Registry;
/// Point `get_app_dir` at a throwaway home so `log_path_for` resolves
/// under a temp dir. Restoration runs from an RAII guard so a panicking
/// `f()` cannot leak the temp env into later serialized tests.
fn with_temp_home<F: FnOnce()>(f: F) {
struct EnvGuard {
home: Option<std::ffi::OsString>,
xdg: Option<std::ffi::OsString>,
}
impl Drop for EnvGuard {
fn drop(&mut self) {
// SAFETY: tests are serialized via `#[serial]`.
unsafe {
match self.home.take() {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
match self.xdg.take() {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
}
}
let tmp = TempDir::new().unwrap();
let _guard = EnvGuard {
home: std::env::var_os("HOME"),
xdg: std::env::var_os("XDG_CONFIG_HOME"),
};
// SAFETY: tests are serialized via `#[serial]`; the guard restores
// the originals on scope exit, including an unwind.
unsafe {
std::env::set_var("HOME", tmp.path());
std::env::set_var("XDG_CONFIG_HOME", tmp.path().join(".config"));
}
f();
}
fn read_log(session: &str) -> String {
let p = crate::process::worker_registry::log_path_for(session).unwrap();
std::fs::read_to_string(p).unwrap_or_default()
}
#[test]
#[serial]
fn routes_event_with_session_field_to_its_file() {
with_temp_home(|| {
let sub = Registry::default().with(SessionTeeLayer::new());
with_default(sub, || {
tracing::warn!(target: "acp.protocol", session = %"sess-a", "watchdog fired");
});
let body = read_log("sess-a");
assert!(body.contains("watchdog fired"), "got: {body}");
assert!(body.contains("acp.protocol"), "got: {body}");
});
}
#[test]
#[serial]
fn no_cross_session_leakage() {
with_temp_home(|| {
let sub = Registry::default().with(SessionTeeLayer::new());
with_default(sub, || {
tracing::info!(target: "acp.protocol", session = %"sess-x", "x only");
tracing::info!(target: "acp.protocol", session = %"sess-y", "y only");
});
let x = read_log("sess-x");
let y = read_log("sess-y");
assert!(x.contains("x only") && !x.contains("y only"), "x log: {x}");
assert!(y.contains("y only") && !y.contains("x only"), "y log: {y}");
});
}
#[test]
#[serial]
fn drops_event_without_session() {
with_temp_home(|| {
let sub = Registry::default().with(SessionTeeLayer::new());
with_default(sub, || {
tracing::info!(target: "acp.protocol", "no session here");
});
let dir = crate::process::worker_registry::workers_dir().unwrap();
let count = std::fs::read_dir(&dir).map(|rd| rd.count()).unwrap_or(0);
assert_eq!(count, 0, "a sessionless event must not create a log file");
});
}
#[test]
#[serial]
fn inherits_session_from_span_scope() {
with_temp_home(|| {
let sub = Registry::default().with(SessionTeeLayer::new());
with_default(sub, || {
let span = tracing::info_span!("acp_session", session = %"sess-span");
let _g = span.enter();
// Event carries no explicit `session` field; it must be
// attributed via the enclosing span scope.
tracing::warn!(target: "acp.protocol", "inherited via span");
});
let body = read_log("sess-span");
assert!(body.contains("inherited via span"), "got: {body}");
});
}
#[test]
#[serial]
fn skips_tee_target_to_avoid_reentrancy() {
with_temp_home(|| {
let sub = Registry::default().with(SessionTeeLayer::new());
with_default(sub, || {
tracing::warn!(target: "acp.tee", session = %"sess-z", "internal");
});
assert!(
read_log("sess-z").is_empty(),
"events on the acp.tee target must be skipped"
);
});
}
}
+12 -206
View File
@@ -37,7 +37,6 @@ use super::agent_registry::{AgentRegistry, AgentSpec};
use super::approvals::{ApprovalDecision, Nonce};
use super::elicitations::{ElicitationOutcome, ElicitationResolution};
use super::state::{AcpSessionId, Event, RateLimitInfo};
use crate::session::SandboxInfo;
/// Maximum number of post-startup respawns within `RESTART_WINDOW`.
/// After this many crashes the session is parked and an
@@ -525,18 +524,8 @@ pub struct SpawnRequest {
/// advertises the capability) rather than `session/new` / `session/load`.
/// Sourced from `Instance.fork_pending`.
pub fork_from: Option<String>,
/// When `Some`, the agent runs inside the named Docker container.
/// The supervisor wraps the agent argv in `docker exec` and the
/// daemon-side fs/terminal handlers route across the container
/// boundary using the container_workdir / mount map derived from
/// `Instance`'s container_config. `None` keeps the legacy host
/// spawn behavior.
pub sandbox_info: Option<SandboxInfo>,
/// Source profile of the session. Used (with `sandbox_info`) to
/// resolve profile-level `sandbox.environment` so structured view-sandbox
/// env matches the tmux view. `None` for non-sandboxed
/// sessions; falls back to the user's default profile when set
/// to `Some("")`.
/// Source profile of the session. `None` falls back to the user's
/// default profile, as does `Some("")`.
pub source_profile: Option<String>,
/// When true, switch the session to `bypassPermissions` mode
/// immediately after `session/new` succeeds, so a profile with
@@ -1432,7 +1421,6 @@ impl<S: BroadcastSink> Supervisor<S> {
effort,
stored_acp_session_id,
fork_from,
sandbox_info,
source_profile,
yolo_mode,
acp_mode_id,
@@ -1524,14 +1512,9 @@ impl<S: BroadcastSink> Supervisor<S> {
let default_mode = acp_defaults.and_then(|defaults| defaults.mode());
// `Config.environment` is trusted global/profile configuration; repo
// overrides cannot contribute it. Mirror terminal-view behavior for
// host agents, while sandboxed agents continue to use the separate
// `sandbox.environment` namespace.
let mut host_environment = if sandbox_info.is_none() {
crate::session::environment::resolve_host_environment_pairs(&resolved_cfg.environment)
} else {
Vec::new()
};
// overrides cannot contribute it. Mirrors terminal-view behavior.
let mut host_environment =
crate::session::environment::resolve_host_environment_pairs(&resolved_cfg.environment);
// `host_hooks.before_session` mints env for a host agent at spawn time,
// the structured-view counterpart of the terminal view's tmux `-e`
@@ -1553,7 +1536,7 @@ impl<S: BroadcastSink> Supervisor<S> {
// computes. It can only be empty when the trusted value is empty; the
// re-resolve stays as the belt-and-suspenders that actually enforces the
// boundary.
if sandbox_info.is_none() && !resolved_cfg.host_hooks.before_session.is_empty() {
if !resolved_cfg.host_hooks.before_session.is_empty() {
let profile_for_hook = source_profile.clone().unwrap_or_default();
let cwd_for_hook = cwd.clone();
let session_for_hook = session_id.clone();
@@ -1625,7 +1608,6 @@ impl<S: BroadcastSink> Supervisor<S> {
socket_path: Some(socket_path),
stored_acp_session_id: stored_acp_session_id.clone(),
fork_from,
sandbox_info,
source_profile,
seed_history_replay,
artifact_dir: crate::session::artifacts::session_artifact_dir(&session_id).ok(),
@@ -2099,15 +2081,12 @@ impl<S: BroadcastSink> Supervisor<S> {
tokio::time::sleep(RESPAWN_BACKOFF).await;
// Re-run `host_hooks.before_session` before the respawn, the
// same way `spawn_inner` runs it on first spawn: a
// non-sandboxed worker respawns often (crash, cancel
// watchdog, transport break), and reusing the value minted
// at the original spawn would defeat the hook's whole
// purpose of refreshing a short-lived value (e.g. a rotated
// token) on every launch. Sandboxed agents keep using
// `sandbox.environment`, resolved once at container
// bring-up, so this is skipped for them.
if respawn_config.sandbox_info.is_none() {
// same way `spawn_inner` runs it on first spawn: a worker
// respawns often (crash, cancel watchdog, transport break),
// and reusing the value minted at the original spawn would
// defeat the hook's whole purpose of refreshing a
// short-lived value (e.g. a rotated token) on every launch.
{
let profile_for_hook =
respawn_config.source_profile.clone().unwrap_or_default();
let cwd_for_hook = respawn_config.cwd.clone();
@@ -2756,7 +2735,6 @@ impl<S: BroadcastSink> Supervisor<S> {
cwd: PathBuf,
additional_dirs: Vec<PathBuf>,
in_flight_turn: bool,
sandbox: Option<SandboxInfo>,
) -> Result<(), SupervisorError> {
// Reserve a `pending_resumes` slot for the duration of the
// attach so the UI shows "Resuming…" while the socket dial +
@@ -2867,26 +2845,6 @@ impl<S: BroadcastSink> Supervisor<S> {
// written before the field existed serialize to `None`, in which
// case `current_env_entries` warns and falls back to the global
// default profile (matching pre-persistence behavior).
let sandbox_resources = match sandbox {
Some(info) => {
// `from_info` resolves the container workdir, which touches git2
// and (for a legacy session with no pinned workdir) shells out to
// `docker inspect`. Run it off the async executor, mirroring how
// `ensure_container_for_session` wraps its docker work.
let cwd = cwd.clone();
let profile = record.source_profile.clone();
Some(
tokio::task::spawn_blocking(move || {
super::acp_client::SessionSandbox::from_info(&info, cwd.as_path(), profile)
})
.await
.map_err(|e| {
AcpError::Spawn(format!("sandbox resolve task panicked: {e}"))
})??,
)
}
None => None,
};
let mut client = AcpClient::attach(
record.socket_path.clone(),
cwd,
@@ -2894,7 +2852,6 @@ impl<S: BroadcastSink> Supervisor<S> {
stored_acp_session_id,
in_flight_turn,
acp_session_id,
sandbox_resources,
attach_agent_key,
record.source_profile.clone(),
)
@@ -3795,146 +3752,6 @@ cursor-acp-bridge = "agent acp"
);
}
/// #3241: the reattach half of the enforcement. Workers are detached rather
/// than killed on daemon shutdown, so a runner started under a permissive
/// policy is still alive when the policy tightens. Attaching it would let it
/// outlive the restriction, so it must be terminated and its registry
/// record cleared instead. This is the test that would have caught the
/// original plan's bypass.
///
/// `#[serial]` because it mutates `HOME` / `XDG_CONFIG_HOME` to point the
/// app dir (config + worker registry) at a temp dir.
#[tokio::test]
#[serial_test::serial]
async fn attach_terminates_a_worker_whose_agent_is_no_longer_allowed() {
// Root under /tmp, not $TMPDIR: on macOS the latter is deep enough that
// <app_dir>/acp-workers/<id>.sock blows past the sun_path limit.
let tmp = tempfile::TempDir::with_prefix_in("aoe-attach-policy-", "/tmp").unwrap();
let original_home = std::env::var_os("HOME");
let original_xdg = std::env::var_os("XDG_CONFIG_HOME");
// SAFETY: serialized via `#[serial]`; restored before returning.
unsafe {
std::env::set_var("HOME", tmp.path());
std::env::set_var("XDG_CONFIG_HOME", tmp.path().join(".config"));
}
// A stand-in runner: `is_record_live` requires a live pid, and the
// terminate path signals the pid's whole process group. `process_group(0)`
// makes the child its own group leader so the killpg lands on it alone;
// using our own pid here would SIGTERM the test process.
use std::os::unix::process::CommandExt as _;
let mut fake_runner = std::process::Command::new("sleep")
.arg("60")
.process_group(0)
.spawn()
.expect("spawn stand-in runner");
let result = async {
let sup = Supervisor::new(VecSink::new());
let socket = crate::process::worker_registry::socket_path_for("s-policy").unwrap();
std::fs::write(&socket, b"").unwrap();
let mut record = crate::process::worker_registry::WorkerRecord::new(
"s-policy".into(),
fake_runner.id(),
socket,
"codex-acp".into(),
"codex".into(),
tmp.path().to_path_buf(),
None,
vec![],
vec![],
Some("acp-session".into()),
None,
);
record.detached_at = Some(1);
// Control first, while the stand-in runner is still alive: a policy
// that permits `codex` clears the gate and the attach proceeds to
// dial, failing only because the socket path is a plain file rather
// than a listening runner. This is what proves the denial below
// comes from the policy and not from the fixture.
crate::session::config::update_config(|c| {
c.acp.restrict_agents = true;
c.acp.allowed_agents = vec!["claude".to_string(), "codex".to_string()];
})
.unwrap();
crate::process::worker_registry::save(&record).unwrap();
let allowed = sup
.attach(
"s-policy".into(),
tmp.path().to_path_buf(),
vec![],
false,
None,
)
.await;
assert!(
!matches!(allowed, Err(SupervisorError::AgentNotAllowed(_))),
"a permitted agent must clear the policy gate, got {allowed:?}"
);
// Now tighten the policy so `codex` is no longer permitted.
crate::session::config::update_config(|c| {
c.acp.allowed_agents = vec!["claude".to_string()];
})
.unwrap();
crate::process::worker_registry::save(&record).unwrap();
let got = sup
.attach(
"s-policy".into(),
tmp.path().to_path_buf(),
vec![],
false,
None,
)
.await;
// Refused as a policy decision; the record is gone so the next
// reconciler tick cannot reattach the same runner; and the runner
// itself was signalled rather than left holding its credentials.
assert!(
matches!(got, Err(SupervisorError::AgentNotAllowed(ref n)) if n == "codex"),
"expected AgentNotAllowed(codex), got {got:?}"
);
assert!(
crate::process::worker_registry::load("s-policy")
.unwrap()
.is_none(),
"the disallowed worker's registry record must be cleared"
);
// Poll rather than `wait()`: the child is a `sleep 60`, so a
// terminate path that stops signalling would block this test for a
// full minute before reporting.
let exit = (0..50)
.find_map(|_| {
let got = fake_runner.try_wait().unwrap();
if got.is_none() {
std::thread::sleep(std::time::Duration::from_millis(100));
}
got
})
.expect("the disallowed runner must be signalled, not left running");
assert!(
exit.code().is_none(),
"the runner must exit from a signal, not a normal exit: {exit:?}"
);
}
.await;
unsafe {
match original_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
match original_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
result
}
#[tokio::test]
async fn spawn_unknown_agent_errors_cleanly() {
let sink = VecSink::new();
@@ -3952,7 +3769,6 @@ cursor-acp-bridge = "agent acp"
stored_acp_session_id: None,
fork_from: None,
seed_history_replay: false,
sandbox_info: None,
source_profile: None,
yolo_mode: false,
acp_mode_id: None,
@@ -3996,7 +3812,6 @@ cursor-acp-bridge = "agent acp"
stored_acp_session_id: None,
fork_from: None,
seed_history_replay: false,
sandbox_info: None,
source_profile: None,
yolo_mode: false,
acp_mode_id: None,
@@ -4058,7 +3873,6 @@ cursor-acp-bridge = "agent acp"
fork_from: None,
seed_history_replay: false,
artifact_dir: None,
sandbox_info: None,
source_profile: None,
};
// Save a registry record so the runner-managed `registry_gone`
@@ -4154,7 +3968,6 @@ cursor-acp-bridge = "agent acp"
fork_from: None,
seed_history_replay: false,
artifact_dir: None,
sandbox_info: None,
source_profile: None,
};
{
@@ -4270,7 +4083,6 @@ cursor-acp-bridge = "agent acp"
fork_from: None,
seed_history_replay: false,
artifact_dir: None,
sandbox_info: None,
source_profile: None,
};
{
@@ -4349,7 +4161,6 @@ cursor-acp-bridge = "agent acp"
fork_from: None,
seed_history_replay: false,
artifact_dir: None,
sandbox_info: None,
source_profile: None,
};
{
@@ -4482,7 +4293,6 @@ cursor-acp-bridge = "agent acp"
fork_from: None,
seed_history_replay: false,
artifact_dir: None,
sandbox_info: None,
source_profile: None,
};
{
@@ -5860,7 +5670,6 @@ cursor-acp-bridge = "agent acp"
stored_acp_session_id: None,
fork_from: None,
seed_history_replay: false,
sandbox_info: None,
source_profile: None,
yolo_mode: false,
acp_mode_id: None,
@@ -5938,7 +5747,6 @@ cursor-acp-bridge = "agent acp"
stored_acp_session_id: None,
fork_from: None,
seed_history_replay: false,
sandbox_info: None,
source_profile: None,
yolo_mode: false,
acp_mode_id: None,
@@ -6200,7 +6008,6 @@ cursor-acp-bridge = "agent acp"
stored_acp_session_id: None,
fork_from: None,
seed_history_replay: false,
sandbox_info: None,
source_profile: None,
yolo_mode: false,
acp_mode_id: None,
@@ -6257,7 +6064,6 @@ cursor-acp-bridge = "agent acp"
stored_acp_session_id: None,
fork_from: None,
seed_history_replay: false,
sandbox_info: None,
source_profile: None,
yolo_mode: false,
acp_mode_id: None,
+11 -246
View File
@@ -19,23 +19,6 @@ use tokio::process::Command;
use tokio::sync::Mutex;
use tracing::info;
use crate::containers::container_interface::{docker_env_args, EnvEntry};
/// Routing target for a terminal command. Built once per session in
/// `SessionResources::sandbox` and consulted on every `terminal/create`.
#[derive(Debug, Clone)]
pub struct TerminalSandbox {
pub container_name: String,
/// Resolved env entries to forward into the container for this command.
///
/// Without this, ACP `terminal/create` would silently rely on whatever
/// env was baked into the container at `docker run` time (which is set
/// once and never updated when host vars change or rotate). The tmux
/// session path already re-resolves per exec; this brings the agent's
/// shell-command path to parity.
pub env_entries: Vec<EnvEntry>,
}
#[derive(Debug, Error)]
pub enum TerminalError {
#[error("io error: {0}")]
@@ -72,30 +55,6 @@ struct TerminalManagerInner {
outputs: std::collections::HashMap<TerminalId, TerminalOutput>,
}
/// Build the `docker exec` argv and inherit-env pairs for a sandboxed
/// `terminal/create` request. Pulled out of `create_and_run` so the wiring
/// can be unit tested without spawning docker. The runtime binary is
/// intentionally not in the result, because the caller picks it based on
/// the active runtime.
pub(crate) fn build_sandbox_exec_args(
sandbox: &TerminalSandbox,
cwd: &std::path::Path,
command: &str,
args: &[String],
) -> (Vec<String>, Vec<(String, String)>) {
let (env_argv, inherit_pairs) = docker_env_args(&sandbox.env_entries);
let mut full_args: Vec<String> = vec![
"exec".into(),
"-w".into(),
cwd.to_string_lossy().into_owned(),
];
full_args.extend(env_argv);
full_args.push(sandbox.container_name.clone());
full_args.push(command.to_string());
full_args.extend(args.iter().cloned());
(full_args, inherit_pairs)
}
impl TerminalManager {
pub fn new() -> Self {
Self::default()
@@ -107,18 +66,13 @@ impl TerminalManager {
/// `output` after a brief delay) for results.
///
/// `cwd` is the working directory; the caller is responsible for
/// passing the session's worktree path. When `sandbox` is `Some`
/// the command is routed through `docker exec` so it runs inside
/// the session's sandbox container; `cwd` is interpreted as a
/// container path in that case (the agent already speaks in
/// container paths).
/// passing the session's worktree path.
pub async fn create_and_run(
&self,
session_id: &str,
command: &str,
args: Vec<String>,
cwd: PathBuf,
sandbox: Option<&TerminalSandbox>,
) -> Result<TerminalId, TerminalError> {
let id = format!("term-{}", uuid::Uuid::new_v4().simple());
info!(
@@ -127,40 +81,19 @@ impl TerminalManager {
terminal = %id,
command = %command,
cwd = %cwd.display(),
sandboxed = sandbox.is_some(),
"terminal/create"
);
let child = match sandbox {
Some(s) => {
let runtime = crate::containers::get_container_runtime();
let binary = runtime.base.binary;
let (full_args, inherit_pairs) = build_sandbox_exec_args(s, &cwd, command, &args);
let mut cmd = Command::new(binary);
cmd.args(&full_args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
// If the prompt task is dropped (worker teardown on a
// force-stop / cancel-escalation restart), kill the child
// instead of leaking it. See #1727.
.kill_on_drop(true);
for (k, v) in inherit_pairs {
cmd.env(k, v);
}
cmd.spawn()?
}
None => Command::new(command)
.args(&args)
.current_dir(&cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
// Kill the child if the prompt task is dropped on a
// force-stop / cancel-escalation worker teardown. See #1727.
.kill_on_drop(true)
.spawn()?,
};
let child = Command::new(command)
.args(&args)
.current_dir(&cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
// Kill the child if the prompt task is dropped on a
// force-stop / cancel-escalation worker teardown. See #1727.
.kill_on_drop(true)
.spawn()?;
// Drain stdout, stderr, and wait() concurrently. Reading
// stdout to EOF before stderr deadlocks when the child fills
@@ -201,171 +134,3 @@ impl TerminalManager {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn create_runs_and_captures_output() {
let mgr = TerminalManager::new();
let cwd = std::env::temp_dir();
let id = mgr
.create_and_run("s-1", "echo", vec!["hello".into()], cwd, None)
.await
.unwrap();
let out = mgr.output(&id).await.unwrap();
assert!(out.stdout.contains("hello"));
assert_eq!(out.exit_code, Some(0));
}
#[tokio::test]
async fn release_removes_terminal() {
let mgr = TerminalManager::new();
let cwd = std::env::temp_dir();
let id = mgr
.create_and_run("s-1", "true", vec![], cwd, None)
.await
.unwrap();
mgr.release(&id).await.unwrap();
let result = mgr.output(&id).await;
assert!(matches!(result, Err(TerminalError::UnknownTerminal(_))));
}
// Regression: pipe-buffer deadlock when stderr exceeds the
// OS pipe buffer (~64 KiB Linux, ~16 KiB macOS) before stdout
// closes. 200 KiB on stderr clears both thresholds. Pre-fix
// this test hangs forever; the outer timeout makes a regression
// surface as a fast failure instead of a stalled CI job.
#[tokio::test]
async fn large_stderr_does_not_deadlock() {
let mgr = TerminalManager::new();
let cwd = std::env::temp_dir();
let script = "head -c 204800 /dev/zero | tr '\\0' 'x' >&2; echo done";
let run = mgr.create_and_run(
"s-large-stderr",
"sh",
vec!["-c".into(), script.into()],
cwd,
None,
);
let id = tokio::time::timeout(std::time::Duration::from_secs(5), run)
.await
.expect("create_and_run hung; pipe deadlock regressed")
.expect("create_and_run failed");
let out = mgr.output(&id).await.unwrap();
assert_eq!(out.stderr.len(), 204_800);
assert!(out.stdout.contains("done"));
assert_eq!(out.exit_code, Some(0));
}
#[tokio::test]
async fn large_stdout_and_stderr_drain_concurrently() {
let mgr = TerminalManager::new();
let cwd = std::env::temp_dir();
let script = "head -c 204800 /dev/zero | tr '\\0' 'o' \
& head -c 204800 /dev/zero | tr '\\0' 'e' >&2 \
& wait";
let run = mgr.create_and_run(
"s-both-pipes",
"sh",
vec!["-c".into(), script.into()],
cwd,
None,
);
let id = tokio::time::timeout(std::time::Duration::from_secs(5), run)
.await
.expect("create_and_run hung; pipe deadlock regressed")
.expect("create_and_run failed");
let out = mgr.output(&id).await.unwrap();
assert_eq!(out.stdout.len(), 204_800);
assert_eq!(out.stderr.len(), 204_800);
assert_eq!(out.exit_code, Some(0));
}
#[test]
fn sandbox_exec_args_emit_e_flags_for_each_entry() {
let sandbox = TerminalSandbox {
container_name: "aoe-sandbox-test".into(),
env_entries: vec![
EnvEntry::Inherit {
key: "GH_TOKEN".into(),
value: "ghp_secret".into(),
},
EnvEntry::Literal {
key: "TERM".into(),
value: "xterm".into(),
},
],
};
let (argv, inherit) = build_sandbox_exec_args(
&sandbox,
std::path::Path::new("/workspace"),
"gh",
&["pr".into(), "list".into()],
);
// exec -w /workspace [-e flags...] container cmd [args...]
assert_eq!(argv[0], "exec");
assert_eq!(argv[1], "-w");
assert_eq!(argv[2], "/workspace");
// Both -e flags must appear before the container name.
let container_idx = argv
.iter()
.position(|a| a == "aoe-sandbox-test")
.expect("container name in argv");
let e_positions: Vec<usize> = argv
.iter()
.enumerate()
.filter_map(|(i, a)| (a == "-e").then_some(i))
.collect();
assert_eq!(e_positions.len(), 2, "one -e flag per env entry");
for pos in &e_positions {
assert!(
*pos < container_idx,
"-e flags must precede the container name"
);
}
// Inherit value must NOT leak into argv.
assert!(
!argv.iter().any(|a| a.contains("ghp_secret")),
"secret leaked into argv: {:?}",
argv
);
// Inherit pairs carry the actual value for cmd.env(k, v).
assert_eq!(
inherit,
vec![("GH_TOKEN".to_string(), "ghp_secret".to_string())]
);
// Command + args appear after the container name.
assert_eq!(argv[container_idx + 1], "gh");
assert_eq!(argv[container_idx + 2], "pr");
assert_eq!(argv[container_idx + 3], "list");
}
#[test]
fn sandbox_exec_args_no_env_entries() {
let sandbox = TerminalSandbox {
container_name: "aoe-sandbox-test".into(),
env_entries: vec![],
};
let (argv, inherit) = build_sandbox_exec_args(
&sandbox,
std::path::Path::new("/workspace"),
"echo",
&["hi".into()],
);
assert_eq!(
argv,
vec![
"exec".to_string(),
"-w".to_string(),
"/workspace".to_string(),
"aoe-sandbox-test".to_string(),
"echo".to_string(),
"hi".to_string(),
]
);
assert!(inherit.is_empty());
}
}
+1 -43
View File
@@ -161,16 +161,7 @@ pub fn gates_needed_by_instances(instances: &[Instance]) -> Vec<VersionGate> {
let registry = AgentRegistry::with_defaults();
let mut seen = HashSet::new();
let mut gates = Vec::new();
// Only host-run structured sessions gate on the host toolchain. A
// sandboxed session's adapter lives inside its container, so probing
// `claude-agent-acp --version` on the host would report Missing (the
// host never installs it) and emit a bogus "upgrade the ACP package"
// warning at every `aoe serve` boot. The in-container adapter is
// validated at handshake time by `agent_compat::validate` instead.
for inst in instances
.iter()
.filter(|inst| inst.is_structured() && !inst.is_sandboxed())
{
for inst in instances.iter().filter(|inst| inst.is_structured()) {
let explicit_agent = inst.agent_name.as_deref().filter(|name| !name.is_empty());
let Some(spec) = explicit_agent
.and_then(|agent| registry.get(agent))
@@ -344,37 +335,4 @@ mod tests {
.any(|g| g.min_version == CLAUDE_AGENT_ACP_MIN_VERSION));
assert!(gates.iter().any(|g| g.min_version == OPENCODE_MIN_VERSION));
}
#[test]
fn gates_needed_by_instances_skips_sandboxed_sessions() {
// A sandboxed structured session runs its adapter inside the
// container; the host probe would falsely report it Missing. Only
// a host-run structured session on the same tool should contribute
// a gate. See the sandbox-only false-warning fix.
let mut sandboxed = Instance::new("sandboxed", "/tmp/sandboxed");
sandboxed.view = View::Structured;
sandboxed.tool = "claude".to_string();
sandboxed.sandbox_info = Some(crate::session::SandboxInfo {
enabled: true,
container_id: None,
image: "ghcr.io/agent-of-empires/aoe-sandbox:latest".to_string(),
container_name: "aoe-sandbox-sandboxe".to_string(),
extra_env: None,
custom_instruction: None,
container_workdir: None,
before_start_env: Vec::new(),
});
assert!(sandboxed.is_sandboxed());
// Only the sandboxed claude session exists: no host gate is emitted.
assert!(gates_needed_by_instances(&[sandboxed.clone()]).is_empty());
// A host-run claude session alongside it still contributes its gate.
let mut host_claude = Instance::new("host", "/tmp/host");
host_claude.view = View::Structured;
host_claude.tool = "claude".to_string();
let gates = gates_needed_by_instances(&[sandboxed, host_claude]);
assert_eq!(gates.len(), 1);
assert_eq!(gates[0].min_version, CLAUDE_AGENT_ACP_MIN_VERSION);
}
}
+16 -31
View File
@@ -20,11 +20,10 @@ pub enum AcpCommands {
/// Emit machine-readable JSON instead of a human report.
#[arg(long)]
json: bool,
/// Attempt safe remediations: download the bundled Node runtime if
/// none is present, then install the pinned npm ACP adapter into the
/// data dir with that Node's own npm (no global install, no sudo).
/// Installs claude-agent-acp by default; each adapter is a separate
/// several-hundred-MB tree, so pick others with --adapter.
/// Attempt safe remediations: install the pinned npm ACP adapter
/// into the data dir with the host Node's npm (no global install,
/// no sudo). Installs claude-agent-acp by default; each adapter is a
/// separate several-hundred-MB tree, so pick others with --adapter.
#[arg(long)]
fix: bool,
/// Adapter to install with --fix (repeatable). Defaults to
@@ -358,39 +357,25 @@ fn adapters_to_install(requested: &[String], all: bool) -> Result<Vec<&'static s
async fn doctor(json: bool, fix: bool, adapter: Vec<String>, all_adapters: bool) -> Result<()> {
if fix {
// Resolve a usable Node (download the pinned bundled runtime when
// the host has none), then install the pinned npm ACP adapters into
// the data dir with that Node's own npm. No `npm install -g`, no
// sudo, a version aoe controls. See #1017.
// Resolve the host's Node, then install the pinned npm ACP adapters
// into the data dir with its npm. No `npm install -g`, no sudo, a
// version aoe controls. See #1017.
match crate::session::get_app_dir() {
Err(e) => println!(
"Cannot resolve the app data dir ({e}); skipping the Node and adapter install."
),
Err(e) => {
println!("Cannot resolve the app data dir ({e}); skipping the adapter install.")
}
Ok(app_dir) => {
let node = match node::resolve("", &app_dir) {
let node = match node::resolve("") {
Ok(node) => {
println!("Node available: {} ({})", node.path.display(), node.version);
Some(node)
}
Err(node::NodeError::NoNode(_)) | Err(node::NodeError::TooOld { .. }) => {
println!("Downloading Node {} runtime...", node::PINNED_NODE_VERSION);
match node::download(&app_dir).await {
Ok(node) => {
println!(
"Installed Node {} at {}",
node.version,
node.path.display()
);
Some(node)
}
Err(e) => {
println!("Node download failed: {e}");
None
}
}
}
Err(e) => {
println!("Cannot probe Node: {e}");
println!(
"Cannot use a Node runtime ({e}); \
install Node >= {} on PATH or set `acp.node_path`.",
node::MIN_NODE_MAJOR
);
None
}
};
+4 -146
View File
@@ -5,12 +5,11 @@ use clap::Args;
use std::io::IsTerminal;
use std::path::PathBuf;
use crate::containers;
use crate::session::builder;
use crate::session::repo_config;
use crate::session::{
acquire_session_identity_lock, civilizations, duplicate_session_error, is_duplicate_session,
GroupTree, Instance, SandboxInfo, Storage,
GroupTree, Instance, Storage,
};
/// Parse one `--repo-base <repo>=<branch>` pair. Split on the first `=` so a
@@ -107,14 +106,6 @@ pub struct AddArgs {
#[arg(long = "no-submodules")]
no_submodules: bool,
/// Run session in a container sandbox
#[arg(short = 's', long)]
sandbox: bool,
/// Custom container image for sandbox (implies --sandbox)
#[arg(long = "sandbox-image")]
sandbox_image: Option<String>,
/// Enable YOLO mode (skip permission prompts)
#[arg(short = 'y', long)]
yolo: bool,
@@ -299,8 +290,7 @@ pub async fn run(profile: &str, args: AddArgs) -> Result<()> {
// A terminal fork resumes the parent's captured conversation in place: the
// agent finds that conversation by session id under the SAME working
// directory and filesystem view. Flags that move the cwd (`--worktree` /
// `--new-branch` / `--scratch`) or swap the filesystem (`--sandbox` /
// `--sandbox-image`) silently break that lookup, and a user-supplied launch
// `--new-branch` / `--scratch`) silently break that lookup, and a user-supplied launch
// command carrying its own resume/fork flags collides with the ones the
// Fork intent appends. Reject these up front (before any resource creation)
// rather than launch a fork that can't find its parent. See PR review.
@@ -317,12 +307,6 @@ pub async fn run(profile: &str, args: AddArgs) -> Result<()> {
temporary directory, so the fork could not resume the parent's conversation."
);
}
if args.sandbox || args.sandbox_image.is_some() {
bail!(
"`--fork-from` cannot be combined with --sandbox or --sandbox-image: the sandbox \
changes the agent's filesystem view and breaks the resumed conversation."
);
}
// --cmd-override swaps the launched binary out from under the tool: the
// Fork intent builds its resume+fork flags for `instance.tool`, but the
// override binary may be a different agent that rejects them (or, worse,
@@ -642,7 +626,6 @@ pub async fn run(profile: &str, args: AddArgs) -> Result<()> {
workspace_info_opt.as_ref(),
args.create_branch,
None,
None,
);
return Err(duplicate_session_error(&final_title));
}
@@ -924,46 +907,6 @@ pub async fn run(profile: &str, args: AddArgs) -> Result<()> {
}
}
// Handle sandbox setup
let use_sandbox = args.sandbox || args.sandbox_image.is_some();
let runtime = containers::get_container_runtime();
if use_sandbox || config.sandbox.enabled_by_default {
if !runtime.is_available() {
if use_sandbox {
bail!(
"Container runtime is not installed or not accessible.\n\
Install a supported runtime to use sandbox mode.\n\
Tip: Use 'aoe add' without --sandbox to run directly on host"
);
}
} else {
// Surface env-resolution warnings before container creation so
// typos and missing host vars don't silently produce empty
// values inside the sandbox. Same source the TUI path uses.
for w in crate::session::validate_env_entries(&config.sandbox.environment) {
eprintln!("{}", w);
}
let container_name = containers::DockerContainer::generate_name(&instance.id);
let image = resolve_sandbox_image(
args.sandbox_image.as_deref(),
&config.sandbox.default_image,
runtime.default_sandbox_image(),
);
instance.sandbox_info = Some(SandboxInfo {
enabled: true,
container_id: None,
image,
container_name,
extra_env: None,
custom_instruction: config.sandbox.custom_instruction.clone(),
before_start_env: Vec::new(),
container_workdir: None,
});
}
}
// Check for repository hooks.
// Use the original project path for trust checking (not the worktree/workspace
// path, which won't contain `.agent-of-empires/config.toml`).
@@ -1055,20 +998,7 @@ pub async fn run(profile: &str, args: AddArgs) -> Result<()> {
println!(" {}", cmd);
}
let hook_env = repo_config::lifecycle_env_vars(&instance);
if instance.sandbox_info.is_some() {
instance.get_container_for_instance()?;
let workdir = instance.container_workdir();
if let Some(ref sandbox) = instance.sandbox_info {
repo_config::execute_hooks_in_container(
&hooks.on_create,
&sandbox.container_name,
&workdir,
&hook_env,
)?;
}
} else {
repo_config::execute_hooks(&hooks.on_create, &path, &hook_env)?;
}
repo_config::execute_hooks(&hooks.on_create, &path, &hook_env)?;
println!("✓ on_create hooks completed");
}
}
@@ -1086,7 +1016,6 @@ pub async fn run(profile: &str, args: AddArgs) -> Result<()> {
} else {
None
},
instance.sandbox_info.as_ref().map(|_| instance.id.as_str()),
);
return Err(e);
}
@@ -1107,7 +1036,6 @@ pub async fn run(profile: &str, args: AddArgs) -> Result<()> {
} else {
None
},
instance.sandbox_info.as_ref().map(|_| instance.id.as_str()),
);
return Err(error);
}
@@ -1143,7 +1071,6 @@ pub async fn run(profile: &str, args: AddArgs) -> Result<()> {
} else {
None
},
instance.sandbox_info.as_ref().map(|_| instance.id.as_str()),
);
return Err(duplicate_session_error(&instance.title));
}
@@ -1158,7 +1085,6 @@ pub async fn run(profile: &str, args: AddArgs) -> Result<()> {
} else {
None
},
instance.sandbox_info.as_ref().map(|_| instance.id.as_str()),
);
return Err(e);
}
@@ -1176,9 +1102,6 @@ pub async fn run(profile: &str, args: AddArgs) -> Result<()> {
if let Some(parent) = &args.parent {
println!(" Parent: {}", parent);
}
if instance.sandbox_info.is_some() {
println!(" Sandbox: enabled");
}
if instance.scratch {
println!(" Scratch: yes");
}
@@ -1366,23 +1289,7 @@ fn cleanup_partial_session(
workspace_info: Option<&crate::session::WorkspaceInfo>,
created_branch: bool,
scratch_dir: Option<&std::path::Path>,
container_session_id: Option<&str>,
) {
// Tear down the sandbox container first so its bind mount releases the
// worktree before the git removal below. Best-effort and idempotent: a
// container that was never started yields ContainerNotFound, which is not
// an error. `Some` only when the session is sandboxed.
if let Some(session_id) = container_session_id {
let container = crate::containers::DockerContainer::from_session_id(session_id);
if let crate::containers::Teardown::Failed(e) = container.teardown(session_id) {
tracing::warn!(
target: "cli.add",
"failed to remove sandbox container during partial cleanup for {}: {}",
session_id,
e
);
}
}
if let Some(wt) = worktree_info {
if wt.managed_by_aoe {
if let Ok(git_wt) = crate::git::GitWorktree::new(PathBuf::from(&wt.main_repo_path)) {
@@ -1638,32 +1545,9 @@ fn resolve_named_tool(tool: &str, config: &crate::session::Config) -> Result<Nam
)
}
/// Resolve the sandbox image for a new session.
///
/// Precedence: the explicit `--sandbox-image` flag, then the merged
/// `[sandbox] default_image` from `config` (which `resolve_config_with_repo_or_warn`
/// already layers repo over profile/global, see #1651), then the runtime's
/// hardcoded default. The merged value already carries the global config, so
/// there is no need to reload it from disk for the empty-fallback case.
fn resolve_sandbox_image(
flag: Option<&str>,
merged_default: &str,
hardcoded_default: &str,
) -> String {
if let Some(flag) = flag {
return flag.trim().to_string();
}
let merged = merged_default.trim();
if merged.is_empty() {
hardcoded_default.to_string()
} else {
merged.to_string()
}
}
#[cfg(test)]
mod tests {
use super::{override_launch_binary, parse_repo_base, resolve_sandbox_image};
use super::{override_launch_binary, parse_repo_base};
use crate::session::config::SessionConfig;
#[test]
@@ -1686,8 +1570,6 @@ mod tests {
}
}
const HARDCODED: &str = "ghcr.io/agent-of-empires/aoe-sandbox:latest";
#[test]
fn override_launch_binary_uses_command_override() {
let mut session = SessionConfig::default();
@@ -1735,28 +1617,4 @@ mod tests {
let session = SessionConfig::default();
assert_eq!(override_launch_binary("opencode", &session), None);
}
#[test]
fn flag_overrides_everything() {
let image = resolve_sandbox_image(Some(" custom:flag "), "repo:merged", HARDCODED);
assert_eq!(image, "custom:flag");
}
#[test]
fn merged_default_used_when_no_flag() {
let image = resolve_sandbox_image(None, "ghcr.io/example/custom:latest", HARDCODED);
assert_eq!(image, "ghcr.io/example/custom:latest");
}
#[test]
fn whitespace_merged_falls_back_to_hardcoded() {
let image = resolve_sandbox_image(None, " ", HARDCODED);
assert_eq!(image, HARDCODED);
}
#[test]
fn empty_merged_falls_back_to_hardcoded() {
let image = resolve_sandbox_image(None, "", HARDCODED);
assert_eq!(image, HARDCODED);
}
}
+2 -2
View File
@@ -584,8 +584,8 @@ fn load_instances(profile: &str, profile_explicit: bool) -> Vec<Instance> {
fn is_agent_session_name(name: &str) -> bool {
name.starts_with(crate::tmux::SESSION_PREFIX)
&& !name.starts_with(crate::tmux::TERMINAL_PREFIX)
&& !name.starts_with(crate::tmux::CONTAINER_TERMINAL_PREFIX)
&& !name.starts_with(crate::tmux::TOOL_PREFIX)
&& !name.starts_with(crate::tmux::LEGACY_CONTAINER_TERMINAL_PREFIX)
&& !name.starts_with(crate::tmux::LEGACY_TOOL_PREFIX)
}
fn collect_tmux_states(instances: &mut [Instance]) -> Vec<TmuxState> {
-17
View File
@@ -215,10 +215,6 @@ pub async fn run(profile: &str, args: RemoveArgs) -> Result<()> {
delete_worktree,
config.worktree.delete_branch_on_cleanup,
);
let delete_sandbox = inst.sandbox_info.as_ref().is_some_and(|s| s.enabled)
&& !args.keep_container
&& config.sandbox.auto_cleanup;
let storage_profile = storage.profile().to_string();
let reservation = crate::session::deletion::PurgeTransaction::reserve(
storage,
@@ -227,7 +223,6 @@ pub async fn run(profile: &str, args: RemoveArgs) -> Result<()> {
instance: inst.clone(),
delete_worktree,
delete_branch,
delete_sandbox,
force_delete: args.force,
detach_hooks: false,
keep_scratch: args.keep_scratch,
@@ -298,18 +293,6 @@ pub async fn run(profile: &str, args: RemoveArgs) -> Result<()> {
}
}
}
if let Some(sandbox) = &inst.sandbox_info {
if sandbox.enabled {
if args.keep_container {
println!("Container preserved: {}", sandbox.container_name);
} else if !config.sandbox.auto_cleanup {
println!(
"Container preserved: {} (auto_cleanup disabled in config)",
sandbox.container_name
);
}
}
}
// The transaction durably removed the row before returning. Keep the
// project in the new-session wizard's Recent tab after its last session is
+4 -65
View File
@@ -580,8 +580,6 @@ async fn empty_trash(profile: &str) -> Result<()> {
let delete_worktree =
config.worktree.auto_cleanup && inst.has_managed_worktree_or_workspace();
let delete_branch = delete_worktree && config.worktree.delete_branch_on_cleanup;
let delete_sandbox =
inst.sandbox_info.as_ref().is_some_and(|s| s.enabled) && config.sandbox.auto_cleanup;
let row_storage = Storage::open_unwatched(profile)?;
let reservation = crate::session::deletion::PurgeTransaction::reserve(
row_storage,
@@ -590,7 +588,6 @@ async fn empty_trash(profile: &str) -> Result<()> {
instance: inst.clone(),
delete_worktree,
delete_branch,
delete_sandbox,
force_delete: true,
detach_hooks: false,
keep_scratch: false,
@@ -1039,12 +1036,6 @@ fn launch_imported(profile: &str, ids: &[String]) -> Result<()> {
}
/// CLI handler for `aoe session stop`.
///
/// Treats a docker inspect failure ([`crate::containers::Probe::Unknown`])
/// as "possibly running" so the session stop proceeds rather than printing
/// "Session is not running" against a container whose state cannot be
/// confirmed. The `warn!` for the Unknown case is emitted inside
/// [`crate::session::Instance::stop`], so this call site does not re-warn.
async fn stop_session(profile: &str, args: SessionIdArgs) -> Result<()> {
let storage = Storage::open_unwatched(profile)?;
@@ -1058,13 +1049,7 @@ async fn stop_session(profile: &str, args: SessionIdArgs) -> Result<()> {
let title = inst.title.clone();
let tmux_session = crate::tmux::Session::new(&inst.id, &inst.title)?;
let was_running = tmux_session.exists();
let had_container = inst.is_sandboxed()
&& match crate::containers::DockerContainer::from_session_id(&inst.id).probe_running() {
crate::containers::Probe::Running | crate::containers::Probe::Unknown(_) => true,
crate::containers::Probe::NotRunning => false,
};
if !was_running && !had_container {
if !was_running {
println!("Session is not running: {}", title);
return Ok(());
}
@@ -1082,11 +1067,7 @@ async fn stop_session(profile: &str, args: SessionIdArgs) -> Result<()> {
);
}
if had_container {
println!("✓ Stopped session and container: {}", title);
} else {
println!("✓ Stopped session: {}", title);
}
println!("✓ Stopped session: {}", title);
Ok(())
}
@@ -1683,7 +1664,6 @@ async fn rename_session(profile: &str, args: RenameArgs) -> Result<()> {
&leaf,
args.rename_branch,
);
let is_sandboxed = inst.is_sandboxed();
if moves_worktree || renames_branch {
// Persisted status can lag the live tmux pane; recompute only when
// the request will mutate the checkout. A cwd/branch-stable title
@@ -1692,13 +1672,7 @@ async fn rename_session(profile: &str, args: RenameArgs) -> Result<()> {
live.source_profile = profile.to_string();
crate::tmux::refresh_session_cache();
live.update_status();
let container_holds = !live.status.blocks_worktree_edit()
&& moves_worktree
&& crate::session::worktree_edit::ensure_sandbox_container_released(
&id,
is_sandboxed,
);
if live.status.blocks_worktree_edit() || container_holds {
if live.status.blocks_worktree_edit() {
bail!("Stop the session before renaming its worktree directory or branch. Disable session.tie_workdir_to_name to relabel a running session.");
}
}
@@ -1711,16 +1685,6 @@ async fn rename_session(profile: &str, args: RenameArgs) -> Result<()> {
},
) {
Ok(outcome) => {
// The dir moved (path changed): a sandbox container created
// against the old path is now stale, so drop it to force a
// fresh create on next start. A branch-only edit leaves the
// path (and the mount) unchanged.
if outcome.new_path != std::path::Path::new(&current_path) {
crate::session::worktree_edit::discard_sandbox_container_after_move(
&id,
is_sandboxed,
);
}
new_path = Some(outcome.new_path.to_string_lossy().to_string());
new_branch = outcome.new_branch;
}
@@ -1840,23 +1804,7 @@ async fn set_worktree_name(profile: &str, args: SetWorktreeNameArgs) -> Result<(
let mut live = inst.clone();
crate::tmux::refresh_session_cache();
live.update_status();
// A sandbox container keeps the worktree dir mounted even while the agent
// is Idle, so the move would fail. The gate drops a merely-stopped
// container to free the mount and only reports held for a live one, which
// the user has to stop, same as the active-status case. Gated on the
// directory actually moving so a no-op or branch-only edit does not discard
// a container for a move that never happens.
let moves_worktree = crate::session::worktree_edit::worktree_move_required(
std::path::Path::new(&current_path),
args.name.trim(),
);
if live.status.blocks_worktree_edit()
|| (moves_worktree
&& crate::session::worktree_edit::ensure_sandbox_container_released(
&id,
live.is_sandboxed(),
))
{
if live.status.blocks_worktree_edit() {
bail!("Cannot edit the workdir name while the session is active; stop it first");
}
@@ -1868,15 +1816,6 @@ async fn set_worktree_name(profile: &str, args: SetWorktreeNameArgs) -> Result<(
rename_branch: args.rename_branch,
},
)?;
// The dir moved (path changed): a sandbox container created against the old
// path is now stale, so drop it to force a fresh create on next start. A
// branch-only edit leaves the path (and the mount) unchanged.
if outcome.new_path != std::path::Path::new(&current_path) {
crate::session::worktree_edit::discard_sandbox_container_after_move(
&id,
live.is_sandboxed(),
);
}
let new_path = outcome.new_path.to_string_lossy().to_string();
let new_branch = outcome.new_branch.clone();
-6
View File
@@ -29,7 +29,6 @@ pub fn run_status(args: TmuxStatusArgs) -> Result<()> {
let json = serde_json::json!({
"title": info.title,
"branch": info.branch,
"sandbox": info.sandbox,
});
println!("{}", serde_json::to_string(&json)?);
} else {
@@ -38,11 +37,6 @@ pub fn run_status(args: TmuxStatusArgs) -> Result<()> {
output.push_str(" | ");
output.push_str(b);
}
if let Some(s) = &info.sandbox {
output.push_str(" [");
output.push_str(s);
output.push(']');
}
print!("{}", output);
}
}
-228
View File
@@ -1,228 +0,0 @@
pub struct VolumeMount {
pub host_path: String,
pub container_path: String,
pub read_only: bool,
}
/// A named Docker/Podman volume mounted at a specific container path.
/// Used by `volume_ignores_strategy = "named"` to bypass VirtioFS shadowing on macOS.
pub struct NamedVolumeMount {
pub volume_name: String,
pub container_path: String,
}
/// An environment variable entry for a container.
///
/// `Inherit` entries use Docker's `-e KEY` form (no value in argv), which reads
/// the value from the calling process's environment. This prevents secrets from
/// leaking into `ps` output.
///
/// `Literal` entries use `-e KEY=VALUE` and are appropriate for non-secret,
/// hard-coded values.
#[derive(Debug, Clone, PartialEq)]
pub enum EnvEntry {
/// Value inherited from host environment. Only the key appears in argv;
/// the value is passed to Docker via the process environment.
Inherit { key: String, value: String },
/// Literal (non-secret) value. Both key and value appear in argv.
Literal { key: String, value: String },
}
impl EnvEntry {
pub fn key(&self) -> &str {
match self {
EnvEntry::Inherit { key, .. } | EnvEntry::Literal { key, .. } => key,
}
}
pub fn value(&self) -> &str {
match self {
EnvEntry::Inherit { value, .. } | EnvEntry::Literal { value, .. } => value,
}
}
}
/// Translate env entries into docker `-e` argv flags plus an inherit list.
///
/// For each `Inherit` entry, pushes `-e KEY` to argv and `(KEY, value)` to the
/// returned inherit list; the caller must apply the inherit pairs to the
/// spawning process's environment via `Command::env(k, v)` so docker can
/// resolve the bare `-e KEY` flag without the value ever appearing in argv
/// or `ps` output. For each `Literal` entry, pushes `-e KEY=VALUE` to argv.
///
/// Both the create path (`docker run`) and every exec path (`docker exec` from
/// tmux sessions, ACP agent spawn, and ACP `terminal/create`) share this
/// translation. Keeping it in one place ensures they cannot drift.
///
/// Dedupes by key (first wins). `collect_environment` already dedupes its
/// output, but the helper repeats the check so any caller that builds its
/// own entry list cannot accidentally emit two `-e KEY` flags for the same
/// key (which docker accepts but with last-write-wins semantics that aren't
/// always intended).
pub fn docker_env_args(entries: &[EnvEntry]) -> (Vec<String>, Vec<(String, String)>) {
let mut argv = Vec::with_capacity(entries.len() * 2);
let mut inherit = Vec::new();
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
for entry in entries {
let key = entry.key();
if !seen.insert(key) {
continue;
}
argv.push("-e".to_string());
match entry {
EnvEntry::Inherit { key, value } => {
argv.push(key.clone());
inherit.push((key.clone(), value.clone()));
}
EnvEntry::Literal { key, value } => {
argv.push(format!("{}={}", key, value));
}
}
}
(argv, inherit)
}
#[derive(Default)]
pub struct ContainerConfig {
pub working_dir: String,
pub volumes: Vec<VolumeMount>,
pub anonymous_volumes: Vec<String>,
/// Named volumes for volume_ignores when strategy = "named". Cleaned up explicitly on session delete.
pub named_ignore_volumes: Vec<NamedVolumeMount>,
pub environment: Vec<EnvEntry>,
pub cpu_limit: Option<String>,
pub memory_limit: Option<String>,
pub port_mappings: Vec<String>,
/// Container network mode passed to `--network`. `None` uses the runtime
/// default (bridge). Set from `sandbox.network`; `bridge` and the rejected
/// `host` value are normalized to `None` before reaching here.
pub network: Option<String>,
/// Append the SELinux relabel flag (`:z`) to host bind mounts so the container
/// can access them on SELinux-enforcing hosts (Fedora, RHEL). Set from
/// `sandbox.selinux_relabel`; only emitted for runtimes that support it.
pub selinux_relabel: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn docker_env_args_inherit_keeps_value_out_of_argv() {
let entries = vec![EnvEntry::Inherit {
key: "GH_TOKEN".to_string(),
value: "ghp_secret".to_string(),
}];
let (argv, inherit) = docker_env_args(&entries);
assert_eq!(argv, vec!["-e".to_string(), "GH_TOKEN".to_string()]);
assert_eq!(
inherit,
vec![("GH_TOKEN".to_string(), "ghp_secret".to_string())]
);
assert!(
!argv.iter().any(|a| a.contains("ghp_secret")),
"secret leaked into argv"
);
}
#[test]
fn docker_env_args_literal_emits_key_eq_value() {
let entries = vec![EnvEntry::Literal {
key: "TERM".to_string(),
value: "xterm-256color".to_string(),
}];
let (argv, inherit) = docker_env_args(&entries);
assert_eq!(
argv,
vec!["-e".to_string(), "TERM=xterm-256color".to_string()]
);
assert!(inherit.is_empty());
}
#[test]
fn docker_env_args_mixed_preserves_order() {
let entries = vec![
EnvEntry::Inherit {
key: "SECRET".to_string(),
value: "s3cr3t".to_string(),
},
EnvEntry::Literal {
key: "TERM".to_string(),
value: "xterm".to_string(),
},
EnvEntry::Inherit {
key: "TOKEN".to_string(),
value: "tok".to_string(),
},
];
let (argv, inherit) = docker_env_args(&entries);
assert_eq!(
argv,
vec![
"-e".to_string(),
"SECRET".to_string(),
"-e".to_string(),
"TERM=xterm".to_string(),
"-e".to_string(),
"TOKEN".to_string(),
]
);
assert_eq!(
inherit,
vec![
("SECRET".to_string(), "s3cr3t".to_string()),
("TOKEN".to_string(), "tok".to_string()),
]
);
}
#[test]
fn docker_env_args_empty() {
let (argv, inherit) = docker_env_args(&[]);
assert!(argv.is_empty());
assert!(inherit.is_empty());
}
#[test]
fn docker_env_args_dedupes_duplicate_keys_first_wins() {
// Guards against a caller that hand-builds entries and accidentally
// passes the same key twice. Docker accepts duplicate `-e` flags
// with last-write-wins, which is rarely what the caller meant.
let entries = vec![
EnvEntry::Inherit {
key: "GH_TOKEN".to_string(),
value: "ghp_first".to_string(),
},
EnvEntry::Literal {
key: "GH_TOKEN".to_string(),
value: "literal_should_be_skipped".to_string(),
},
EnvEntry::Inherit {
key: "OTHER".to_string(),
value: "kept".to_string(),
},
];
let (argv, inherit) = docker_env_args(&entries);
// First GH_TOKEN entry wins; the literal duplicate is dropped.
assert_eq!(
argv,
vec![
"-e".to_string(),
"GH_TOKEN".to_string(),
"-e".to_string(),
"OTHER".to_string(),
]
);
assert_eq!(
inherit,
vec![
("GH_TOKEN".to_string(), "ghp_first".to_string()),
("OTHER".to_string(), "kept".to_string()),
]
);
assert!(
!argv.iter().any(|a| a.contains("literal_should_be_skipped")),
"duplicate key's value leaked into argv"
);
}
}
-202
View File
@@ -1,202 +0,0 @@
use thiserror::Error;
/// Errors from Docker / Podman / Apple Container operations.
///
/// `Display` strings are single-line by convention: the three unit variants
/// (`NotInstalled`, `DaemonNotRunning`, `PermissionDenied`) inline their
/// actionable remediation on one line, and the string-carrying variants
/// (`InspectFailed`, `RemoveFailed`, etc.) must be constructed via
/// `sanitize_stderr` to normalize any embedded newlines from raw runtime
/// stderr. This lets `tracing::warn!(error = %e)` at gate sites emit one
/// physical log line, keeping `grep target` correlation intact under the
/// text-mode subscriber this project uses.
#[derive(Debug, Error)]
pub enum DockerError {
#[error(
"Docker is not installed or not in PATH. Install: https://docs.docker.com/get-docker/"
)]
NotInstalled,
#[error(
"Docker daemon is not running. Start Docker Desktop or run: sudo systemctl start docker"
)]
DaemonNotRunning,
#[error("Docker permission denied. On Linux: add your user to the docker group (sudo usermod -aG docker $USER) and re-login")]
PermissionDenied,
#[error("Container not found: {0}")]
ContainerNotFound(String),
#[error("Container already exists: {0}")]
ContainerAlreadyExists(String),
#[error("Docker image not found: {0}")]
ImageNotFound(String),
#[error("Failed to create container: {0}")]
CreateFailed(String),
#[error("Failed to start container: {0}")]
StartFailed(String),
#[error("Failed to stop container: {0}")]
StopFailed(String),
#[error("Failed to remove container: {0}")]
RemoveFailed(String),
#[error("Failed to inspect container: {0}")]
InspectFailed(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
}
/// Collapse embedded newlines and trim so a raw runtime stderr string,
/// when wrapped in a [`DockerError`] string-carrying variant, still
/// renders as a single-line `Display`.
///
/// Docker / Podman / Apple stderr routinely contains `\n` between error
/// summary lines. Without this, `tracing::warn!(error = %e)` on a gate
/// site would split one logical event across physical log lines,
/// re-introducing the grep-hostility the single-line `Display`
/// convention fixed for the unit variants. Call at every construction
/// site that wraps stderr into a `DockerError` variant.
///
/// Handles Unix (`\n`) and Windows (`\r\n`) line terminators via
/// [`str::lines`], and skips whitespace-only lines so blank interior
/// separators do not become empty ` | ` segments.
///
/// When the input is empty or entirely whitespace, returns the sentinel
/// `"<no stderr>"` rather than an empty string. A runtime CLI can exit
/// non-zero without writing to stderr, and an empty argument would render a
/// string-carrying variant such as `InspectFailed("")` as a dangling
/// `"Failed to inspect container: "` with no operator signal; the sentinel
/// keeps the Display line self-describing.
pub(crate) fn sanitize_stderr(stderr: &str) -> String {
let joined = stderr
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" | ");
if joined.is_empty() {
return "<no stderr>".to_string();
}
joined
}
pub type Result<T> = std::result::Result<T, DockerError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_strings_are_single_line_and_actionable() {
for e in [
DockerError::NotInstalled,
DockerError::DaemonNotRunning,
DockerError::PermissionDenied,
] {
let s = e.to_string();
assert!(!s.contains('\n'), "Display must be single-line: {s:?}");
assert!(s.len() < 160, "Display should fit terminal-wrap: {s:?}");
}
assert!(DockerError::NotInstalled
.to_string()
.contains("docs.docker.com"));
assert!(DockerError::DaemonNotRunning
.to_string()
.contains("systemctl"));
assert!(DockerError::PermissionDenied
.to_string()
.contains("usermod"));
}
#[test]
fn parameterized_variants_stay_single_line_when_stderr_multiline() {
let raw = "Error response from daemon: something failed\nAdditional context: line two\n";
let sanitized = sanitize_stderr(raw);
let e = DockerError::InspectFailed(sanitized);
let rendered = e.to_string();
assert!(
!rendered.contains('\n'),
"sanitize_stderr must strip newlines from parameterized variant Display: {rendered:?}"
);
assert!(
rendered.contains(" | "),
"sanitize_stderr should join lines with ` | ` for readability: {rendered:?}"
);
}
#[test]
fn sanitize_stderr_trims_trailing_whitespace() {
assert_eq!(sanitize_stderr("some error\n"), "some error");
assert_eq!(sanitize_stderr(" padded \n"), "padded");
assert_eq!(sanitize_stderr("a\nb\nc"), "a | b | c");
}
#[test]
fn sanitize_stderr_handles_empty_and_whitespace_inputs() {
// Empty / whitespace-only stderr collapses to the `<no stderr>`
// sentinel so a string-carrying variant never renders a dangling
// colon with no operator signal.
assert_eq!(sanitize_stderr(""), "<no stderr>");
assert_eq!(sanitize_stderr(" "), "<no stderr>");
assert_eq!(sanitize_stderr("\n\n\n"), "<no stderr>");
}
#[test]
fn empty_stderr_variant_display_has_no_dangling_colon() {
// A runtime CLI that exits non-zero without writing to stderr must
// still produce a self-describing Display, not a bare trailing
// "Failed to inspect container: ".
let e = DockerError::InspectFailed(sanitize_stderr(""));
assert_eq!(e.to_string(), "Failed to inspect container: <no stderr>");
}
#[test]
fn sanitize_stderr_handles_crlf_line_endings() {
// `str::lines` splits on `\n` and `\r\n`, so Windows-style
// stderr does not leave a bare `\r` mid-string that would
// corrupt the terminal render.
assert_eq!(sanitize_stderr("a\r\nb"), "a | b");
assert_eq!(
sanitize_stderr("first\r\nsecond\r\nthird"),
"first | second | third"
);
}
#[test]
fn sanitize_stderr_skips_blank_interior_lines() {
// Docker's error responses occasionally include blank lines
// between summary and detail. Filtering them out keeps the
// rendered string readable without empty ` | ` segments.
assert_eq!(sanitize_stderr("line1\n\nline2"), "line1 | line2");
assert_eq!(sanitize_stderr("a\n \nb"), "a | b");
}
#[test]
fn sanitize_stderr_is_idempotent() {
// Applying sanitize_stderr twice must produce the same result
// as applying it once. Protects against a future helper that
// accidentally double-sanitizes and produces ` | | ` sequences.
let once = sanitize_stderr("a\nb\nc");
let twice = sanitize_stderr(&once);
assert_eq!(once, twice);
}
#[test]
fn sanitize_stderr_lone_cr_is_preserved_interior() {
// str::lines only treats \n and \r\n as terminators (per Rust
// spec), so a lone \r mid-string is NOT a line boundary and
// survives str::trim, which strips whitespace only at boundaries.
// Docker/Podman/Apple do not emit lone \r in stderr, so this test
// documents the known limitation rather than exercising a
// real-world path: if a lone \r ever surfaces, it will appear
// verbatim in the Display output.
assert_eq!(sanitize_stderr("a\rb"), "a\rb");
}
}
-438
View File
@@ -1,438 +0,0 @@
//! Detect when the configured sandbox image has a newer build in its registry.
//!
//! The TUI surfaces a "sandbox image update available" banner so users on the
//! `:latest` tag (the default) learn that `ghcr.io/.../aoe-sandbox` moved on
//! and can pull the refresh without leaving the app. The check compares the
//! digest of the locally-stored image against the registry's current digest
//! for the same tag; it never pulls and degrades to "no update" whenever the
//! runtime, image, or network is unavailable.
use anyhow::{anyhow, Result};
/// A detected sandbox-image update: the configured image plus the registry
/// digest it now resolves to. The digest doubles as the snooze key so a
/// dismissal sticks until the registry moves again.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageUpdate {
pub image: String,
pub remote_digest: String,
}
/// Default Accept header advertising every manifest media type a tag might
/// resolve to (single image or multi-arch index, Docker or OCI), so the
/// registry returns a `Docker-Content-Digest` for whichever it serves.
/// User-Agent sent on registry requests; registries reject an empty one.
const USER_AGENT: &str = "agent-of-empires";
const MANIFEST_ACCEPT: &str = "application/vnd.oci.image.index.v1+json, \
application/vnd.docker.distribution.manifest.list.v2+json, \
application/vnd.docker.distribution.manifest.v2+json, \
application/vnd.oci.image.manifest.v1+json";
/// A parsed image reference, split into the registry coordinates needed to
/// query the registry HTTP API.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegistryRef {
/// Registry host (e.g. `ghcr.io`, `registry-1.docker.io`).
pub host: String,
/// Repository path (e.g. `agent-of-empires/aoe-sandbox`, `library/ubuntu`).
pub repository: String,
/// Tag or digest reference (e.g. `latest`, `sha256:...`).
pub reference: String,
/// True when `reference` is a content digest rather than a tag. A
/// digest-pinned image can't go stale, so the staleness check is skipped.
pub pinned: bool,
}
impl RegistryRef {
/// Parse a Docker/OCI image reference into registry coordinates, applying
/// the same defaults the docker CLI uses: a first path segment that looks
/// like a hostname (contains `.`/`:` or is `localhost`) is the registry,
/// otherwise the image lives on Docker Hub and a single-segment name gets
/// the implicit `library/` namespace. Returns `None` for an empty name.
pub fn parse(image: &str) -> Option<Self> {
let image = image.trim();
if image.is_empty() {
return None;
}
// Peel a trailing `@sha256:...` digest first; whatever precedes it is
// the name (and possibly a tag we then ignore for the request).
let (name_and_tag, digest) = match image.split_once('@') {
Some((lhs, rhs)) => (lhs, Some(rhs.to_string())),
None => (image, None),
};
// Split host from the remaining path. Only the first segment can be a
// registry host, and only if it carries a `.`/`:` or is `localhost`.
let (host, remainder) = match name_and_tag.split_once('/') {
Some((first, rest))
if first == "localhost" || first.contains('.') || first.contains(':') =>
{
(first.to_string(), rest.to_string())
}
_ => ("registry-1.docker.io".to_string(), name_and_tag.to_string()),
};
// The tag is the part after the final `:` in the path remainder; a
// colon only counts when it isn't inside a path segment boundary.
let (mut repository, tag) = match remainder.rsplit_once(':') {
Some((repo, tag)) if !tag.contains('/') => (repo.to_string(), Some(tag.to_string())),
_ => (remainder, None),
};
if repository.is_empty() {
return None;
}
// Docker Hub's library namespace is implicit for single-segment names.
if host == "registry-1.docker.io" && !repository.contains('/') {
repository = format!("library/{repository}");
}
let (reference, pinned) = match digest {
Some(d) => (d, true),
None => (tag.unwrap_or_else(|| "latest".to_string()), false),
};
Some(Self {
host,
repository,
reference,
pinned,
})
}
fn manifest_url(&self) -> String {
format!(
"https://{}/v2/{}/manifests/{}",
self.host, self.repository, self.reference
)
}
/// Fetch the registry digest for this reference, performing the standard
/// token handshake when the registry answers the first request with a
/// `401 WWW-Authenticate: Bearer` challenge (ghcr.io and Docker Hub both
/// do for anonymous pulls of public images).
async fn fetch_remote_digest(&self, client: &reqwest::Client) -> Result<String> {
let url = self.manifest_url();
let resp = client
.get(&url)
.header(reqwest::header::ACCEPT, MANIFEST_ACCEPT)
.send()
.await?;
let resp = if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
let token = self.obtain_token(client, &resp).await?;
client
.get(&url)
.header(reqwest::header::ACCEPT, MANIFEST_ACCEPT)
.bearer_auth(token)
.send()
.await?
} else {
resp
};
let resp = resp.error_for_status()?;
resp.headers()
.get("docker-content-digest")
.and_then(|v| v.to_str().ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow!("registry response missing Docker-Content-Digest header"))
}
/// Resolve a bearer token from a `401`'s `WWW-Authenticate: Bearer` header
/// by calling the advertised `realm` with its `service`/`scope` params.
async fn obtain_token(
&self,
client: &reqwest::Client,
challenge: &reqwest::Response,
) -> Result<String> {
let header = challenge
.headers()
.get(reqwest::header::WWW_AUTHENTICATE)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| anyhow!("registry 401 without WWW-Authenticate header"))?;
let params = parse_bearer_challenge(header)
.ok_or_else(|| anyhow!("unsupported WWW-Authenticate challenge: {header}"))?;
let mut url = reqwest::Url::parse(&params.realm)?;
{
let mut qp = url.query_pairs_mut();
if let Some(service) = &params.service {
qp.append_pair("service", service);
}
if let Some(scope) = &params.scope {
qp.append_pair("scope", scope);
}
}
let token: TokenResponse = client
.get(url)
.send()
.await?
.error_for_status()?
.json()
.await?;
token
.token
.or(token.access_token)
.ok_or_else(|| anyhow!("registry token endpoint returned no token"))
}
}
#[derive(serde::Deserialize)]
struct TokenResponse {
token: Option<String>,
access_token: Option<String>,
}
struct BearerChallenge {
realm: String,
service: Option<String>,
scope: Option<String>,
}
/// Parse a `Bearer realm="...",service="...",scope="..."` challenge into its
/// parts. Returns `None` when the scheme isn't `Bearer` or `realm` is absent.
fn parse_bearer_challenge(header: &str) -> Option<BearerChallenge> {
let rest = header.strip_prefix("Bearer ").or_else(|| {
header
.strip_prefix("bearer ")
.or_else(|| header.strip_prefix("BEARER "))
})?;
let mut realm = None;
let mut service = None;
let mut scope = None;
for part in rest.split(',') {
let Some((key, value)) = part.split_once('=') else {
continue;
};
// Strip only a single outer pair of quotes, not every quote, so a
// value that legitimately contains a `"` survives intact.
let value = value.trim();
let value = value
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.unwrap_or(value)
.to_string();
match key.trim() {
"realm" => realm = Some(value),
"service" => service = Some(value),
"scope" => scope = Some(value),
_ => {}
}
}
Some(BearerChallenge {
realm: realm?,
service,
scope,
})
}
/// Pick the `sha256:...` digest matching `image`'s repository out of docker's
/// newline-separated `RepoDigests` (`repo@sha256:...` per line). Falls back to
/// the first entry's digest when none matches the parsed repository, and
/// returns `None` when there are no usable entries (e.g. a locally-built image
/// with an empty `RepoDigests`).
pub fn pick_repo_digest(image: &str, repo_digests: &str) -> Option<String> {
let wanted = RegistryRef::parse(image).map(|r| r.repository);
let mut first = None;
for line in repo_digests.lines() {
let line = line.trim();
let Some((repo, digest)) = line.split_once("@") else {
continue;
};
if digest.is_empty() {
continue;
}
if first.is_none() {
first = Some(digest.to_string());
}
// `repo` carries the registry-qualified path; match on a suffix so a
// `ghcr.io/owner/img@...` entry still matches the parsed `owner/img`.
if let Some(wanted) = &wanted {
if repo == wanted || repo.ends_with(&format!("/{wanted}")) {
return Some(digest.to_string());
}
}
}
first
}
/// Check whether the configured sandbox `image` has a newer digest in its
/// registry than the copy stored locally. Returns:
/// - `Ok(Some(_))` when a different digest is available to pull,
/// - `Ok(None)` when up to date, not locally present, digest-pinned, or the
/// runtime can't report a local digest (nothing actionable to surface),
/// - `Err(_)` when the registry query itself fails (caller logs and stays quiet).
pub async fn check_for_image_update(image: &str) -> Result<Option<ImageUpdate>> {
let Some(reference) = RegistryRef::parse(image) else {
return Ok(None);
};
// A digest-pinned image is already exact; it can't drift.
if reference.pinned {
return Ok(None);
}
// Reading the local digest shells out to the runtime; keep it off the
// async worker threads.
let image_owned = image.to_string();
let local = tokio::task::spawn_blocking(move || {
crate::containers::get_container_runtime().local_image_digest(&image_owned)
})
.await
.ok()
.flatten();
// No local copy (or no reportable digest) means there's nothing to update
// in place; the first sandbox launch pulls it via `ensure_image`.
let Some(local) = local else {
return Ok(None);
};
let client = reqwest::Client::builder()
.user_agent(USER_AGENT)
.timeout(std::time::Duration::from_secs(8))
.build()?;
let remote = reference.fetch_remote_digest(&client).await?;
if remote != local {
tracing::info!(
target: "containers.image_update",
%image,
local = %local,
remote = %remote,
"sandbox image update available"
);
Ok(Some(ImageUpdate {
image: image.to_string(),
remote_digest: remote,
}))
} else {
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_ghcr_reference_with_tag() {
let r = RegistryRef::parse("ghcr.io/agent-of-empires/aoe-sandbox:latest").unwrap();
assert_eq!(r.host, "ghcr.io");
assert_eq!(r.repository, "agent-of-empires/aoe-sandbox");
assert_eq!(r.reference, "latest");
assert!(!r.pinned);
}
#[test]
fn defaults_tag_to_latest() {
let r = RegistryRef::parse("ghcr.io/agent-of-empires/aoe-sandbox").unwrap();
assert_eq!(r.reference, "latest");
assert!(!r.pinned);
}
#[test]
fn applies_docker_hub_library_namespace() {
let r = RegistryRef::parse("ubuntu:22.04").unwrap();
assert_eq!(r.host, "registry-1.docker.io");
assert_eq!(r.repository, "library/ubuntu");
assert_eq!(r.reference, "22.04");
}
#[test]
fn keeps_namespaced_docker_hub_repository() {
let r = RegistryRef::parse("mozillaai/foo").unwrap();
assert_eq!(r.host, "registry-1.docker.io");
assert_eq!(r.repository, "mozillaai/foo");
assert_eq!(r.reference, "latest");
}
#[test]
fn parses_registry_with_port() {
let r = RegistryRef::parse("localhost:5000/team/img:dev").unwrap();
assert_eq!(r.host, "localhost:5000");
assert_eq!(r.repository, "team/img");
assert_eq!(r.reference, "dev");
}
#[test]
fn marks_digest_pinned_references() {
let r = RegistryRef::parse("ghcr.io/agent-of-empires/aoe-sandbox@sha256:abc123def4567890")
.unwrap();
assert!(r.pinned);
assert_eq!(r.reference, "sha256:abc123def4567890");
}
#[test]
fn rejects_empty_reference() {
assert!(RegistryRef::parse("").is_none());
assert!(RegistryRef::parse(" ").is_none());
}
#[test]
fn picks_matching_repo_digest() {
let repo_digests = "\
ghcr.io/agent-of-empires/aoe-sandbox@sha256:aaa\n\
docker.io/library/ubuntu@sha256:bbb\n";
let digest =
pick_repo_digest("ghcr.io/agent-of-empires/aoe-sandbox:latest", repo_digests).unwrap();
assert_eq!(digest, "sha256:aaa");
}
#[test]
fn falls_back_to_first_repo_digest_when_none_match() {
let repo_digests = "registry.example.com/other/img@sha256:ccc\n";
let digest =
pick_repo_digest("ghcr.io/agent-of-empires/aoe-sandbox:latest", repo_digests).unwrap();
assert_eq!(digest, "sha256:ccc");
}
#[test]
fn returns_none_for_empty_repo_digests() {
assert!(pick_repo_digest("ghcr.io/agent-of-empires/aoe-sandbox:latest", "").is_none());
assert!(
pick_repo_digest("ghcr.io/agent-of-empires/aoe-sandbox:latest", "\n \n").is_none()
);
}
#[test]
fn parses_bearer_challenge_fields() {
let header = "Bearer realm=\"https://ghcr.io/token\",service=\"ghcr.io\",scope=\"repository:agent-of-empires/aoe-sandbox:pull\"";
let c = parse_bearer_challenge(header).unwrap();
assert_eq!(c.realm, "https://ghcr.io/token");
assert_eq!(c.service.as_deref(), Some("ghcr.io"));
assert_eq!(
c.scope.as_deref(),
Some("repository:agent-of-empires/aoe-sandbox:pull")
);
}
#[test]
fn rejects_non_bearer_challenge() {
assert!(parse_bearer_challenge("Basic realm=\"x\"").is_none());
}
#[test]
fn strips_only_the_outer_quote_pair() {
// An unquoted value passes through; only a single surrounding pair is
// removed, so an embedded quote is preserved rather than stripped.
let c = parse_bearer_challenge(
"Bearer realm=https://r.example/token,service=svc,scope=\"a\"b\"",
)
.unwrap();
assert_eq!(c.realm, "https://r.example/token");
assert_eq!(c.service.as_deref(), Some("svc"));
assert_eq!(c.scope.as_deref(), Some("a\"b"));
}
}
-443
View File
@@ -1,443 +0,0 @@
pub mod container_interface;
pub mod error;
pub mod image_update;
mod runtime;
pub(crate) mod runtime_base;
pub mod stats;
use std::collections::HashMap;
use crate::cli::truncate_id;
use crate::session::{Config, ContainerRuntimeName};
pub use container_interface::{ContainerConfig, EnvEntry, NamedVolumeMount, VolumeMount};
use error::Result;
pub use runtime::ContainerRuntime;
/// Returns the CLI binary name for the configured container runtime.
pub fn runtime_binary() -> &'static str {
if let Ok(cfg) = Config::load() {
match cfg.sandbox.container_runtime {
ContainerRuntimeName::AppleContainer => "container",
ContainerRuntimeName::Docker => "docker",
ContainerRuntimeName::Podman => "podman",
}
} else {
"docker"
}
}
pub fn get_container_runtime() -> ContainerRuntime {
if let Ok(cfg) = Config::load() {
match cfg.sandbox.container_runtime {
ContainerRuntimeName::AppleContainer => ContainerRuntime::apple_container(),
ContainerRuntimeName::Docker => ContainerRuntime::docker(),
ContainerRuntimeName::Podman => ContainerRuntime::podman(),
}
} else {
ContainerRuntime::default()
}
}
/// Check running state of all aoe sandbox containers in a single subprocess call.
/// Returns a map of container name -> is_running.
pub fn batch_container_health() -> HashMap<String, bool> {
let start = std::time::Instant::now();
let map = get_container_runtime().batch_running_states("aoe-sandbox-");
tracing::debug!(
target: "containers.runtime",
count = map.len(),
duration_ms = start.elapsed().as_millis() as u64,
"batch container health fetched",
);
map
}
/// Resource usage of every aoe sandbox container, in a single subprocess call.
/// Returns a map of container name -> stats; a container the runtime has no
/// usable sample for is absent rather than zeroed.
pub fn batch_container_stats() -> stats::StatsMap {
let start = std::time::Instant::now();
let map = get_container_runtime().batch_stats("aoe-sandbox-");
tracing::debug!(
target: "containers.runtime",
count = map.len(),
duration_ms = start.elapsed().as_millis() as u64,
"batch container stats fetched",
);
map
}
/// Outcome of an idempotent container teardown.
#[derive(Debug)]
pub enum Teardown {
/// The container was force-removed.
Removed,
/// No container existed to remove; the teardown was a no-op.
AlreadyGone,
/// Removal failed for a reason other than the container being absent.
Failed(error::DockerError),
}
/// Classify a force-remove result into an idempotent teardown outcome.
///
/// A `ContainerNotFound` error means there was nothing to remove and maps to
/// `AlreadyGone`; every other error is a genuine `Failed`. Keeping this
/// classification separate from I/O lets it be reasoned about and tested
/// without a live runtime.
fn classify_removal(result: Result<()>) -> Teardown {
match result {
Ok(()) => Teardown::Removed,
Err(error::DockerError::ContainerNotFound(_)) => Teardown::AlreadyGone,
Err(e) => Teardown::Failed(e),
}
}
/// Outcome of a running-state probe that preserves the difference between
/// a definitive "not running" and a transient inspection failure.
///
/// Callers gating a mutation on the container being stopped must match on
/// all three variants and treat [`Probe::Unknown`] conservatively (typically
/// as "possibly running"). Collapsing the underlying `is_running() -> Result<bool>`
/// to a plain `bool` via `unwrap_or(false)` re-introduces the swallowing-
/// existence-probe class of bug fixed in #2596.
/// [`DockerContainer::probe_running`] is the constructor for this type.
#[derive(Debug)]
pub enum Probe {
/// The container is running.
Running,
/// The container is definitively not running (stopped or absent).
NotRunning,
/// The inspection itself failed; the running state is unknown.
Unknown(error::DockerError),
}
/// Classify a running-state result into an idempotent probe outcome.
///
/// A transient inspection error must not be swallowed into a `NotRunning`
/// false negative. Keeping this classification separate from I/O lets it
/// be reasoned about and tested without a live runtime.
fn classify_running_probe(result: Result<bool>) -> Probe {
match result {
Ok(true) => Probe::Running,
Ok(false) => Probe::NotRunning,
Err(e) => Probe::Unknown(e),
}
}
pub struct DockerContainer {
pub name: String,
pub image: String,
runtime: ContainerRuntime,
}
impl DockerContainer {
pub fn new(session_id: &str, image: &str) -> Self {
Self {
name: Self::generate_name(session_id),
image: image.to_string(),
runtime: get_container_runtime(),
}
}
pub fn generate_name(session_id: &str) -> String {
format!("aoe-sandbox-{}", truncate_id(session_id, 8))
}
pub fn from_session_id(session_id: &str) -> Self {
Self {
name: Self::generate_name(session_id),
image: String::new(),
runtime: get_container_runtime(),
}
}
pub fn exists(&self) -> Result<bool> {
self.runtime.does_container_exist(&self.name)
}
pub fn is_running(&self) -> Result<bool> {
self.runtime.is_container_running(&self.name)
}
/// The container's configured working directory, read from the live
/// container. `None` if it can't be determined; see
/// [`ContainerRuntime::container_working_dir`].
pub fn working_dir(&self) -> Option<String> {
self.runtime.container_working_dir(&self.name)
}
pub fn build_create_args(&self, config: &ContainerConfig) -> Vec<String> {
self.runtime
.build_create_args(&self.name, &self.image, config)
}
#[tracing::instrument(target = "containers.runtime", skip_all, fields(name = %self.name, image = %self.image))]
pub fn create(&self, config: &ContainerConfig) -> Result<String> {
tracing::info!(target: "containers.runtime", "creating container");
let result = self
.runtime
.create_container(&self.name, &self.image, config);
match &result {
Ok(id) => tracing::info!(target: "containers.runtime", id = %id, "created"),
Err(e) => tracing::error!(target: "containers.runtime", error = %e, "create failed"),
}
result
}
#[tracing::instrument(target = "containers.runtime", skip_all, fields(name = %self.name))]
pub fn start(&self) -> Result<()> {
tracing::info!(target: "containers.runtime", "starting container");
let result = self.runtime.start_container(&self.name);
if let Err(e) = &result {
tracing::error!(target: "containers.runtime", error = %e, "start failed");
}
result
}
#[tracing::instrument(target = "containers.runtime", skip_all, fields(name = %self.name))]
pub fn stop(&self) -> Result<()> {
tracing::info!(target: "containers.runtime", "stopping container");
let result = self.runtime.stop_container(&self.name);
if let Err(e) = &result {
tracing::warn!(target: "containers.runtime", error = %e, "stop failed");
}
result
}
#[tracing::instrument(target = "containers.runtime", skip_all, fields(name = %self.name, force))]
pub fn remove(&self, force: bool) -> Result<()> {
tracing::info!(target: "containers.runtime", "removing container");
let result = self.runtime.remove(&self.name, force);
if let Err(e) = &result {
tracing::warn!(target: "containers.runtime", error = %e, "remove failed");
}
result
}
/// Remove all named ignore volumes for this session (prefix = `aoe-vi-{session_id}-`).
///
/// Must be called after container removal during session deletion. Named volumes are not
/// removed by `docker rm -v`; they require explicit cleanup. Safe to call even when the
/// container is already gone — volumes can outlive their container.
pub fn remove_named_ignore_volumes(&self, session_id: &str) {
let prefix = format!("aoe-vi-{}-", session_id);
if let Err(e) = self.runtime.base.remove_named_ignore_volumes(&prefix) {
tracing::warn!(
target: "containers.runtime",
name = %self.name,
%session_id,
error = %e,
"failed to remove named ignore volumes"
);
}
}
/// Force-remove this container, then sweep its named ignore volumes.
///
/// Idempotent: a container that is already gone yields
/// [`Teardown::AlreadyGone`], not a failure. Named ignore volumes outlive
/// the container, so they are swept regardless of the removal outcome.
///
/// This method must be invoked unconditionally by callers, which then
/// act on the returned outcome; it must never be gated behind a separate
/// existence probe, whose transient failure would skip removal and orphan
/// a live container.
pub fn teardown(&self, session_id: &str) -> Teardown {
let outcome = classify_removal(self.remove(true));
self.remove_named_ignore_volumes(session_id);
outcome
}
/// Force-remove this container, preserving its named ignore volumes.
///
/// Idempotent counterpart to [`Self::teardown`]: same removal and
/// classification, but the session-scoped named ignore volumes
/// (`aoe-vi-{session_id}-*`, e.g. `target/`, `node_modules/`) are left
/// intact so the recreated container re-attaches them on next start.
/// Used on the worktree-move discard path where the container is dropped
/// to pick up a new bind mount and will be recreated immediately.
///
/// The same invariant as [`Self::teardown`] applies: callers must invoke
/// this unconditionally and act on the returned outcome; it must never
/// be gated behind a separate existence probe, whose transient failure
/// would skip removal and orphan a live container (#2596).
pub fn discard(&self) -> Teardown {
classify_removal(self.remove(true))
}
/// Remove this container only if it is not running, preserving its named
/// ignore volumes.
///
/// The non-force counterpart to [`Self::discard`], for the one caller that
/// legitimately reaches removal through a `probe_running()` gate
/// ([`crate::session::worktree_edit::ensure_sandbox_container_released`]).
/// That gate must not force-remove: it runs unlocked from the CLI and TUI
/// paths, so a container that started between the probe and here would be
/// force-killed under a live agent. Without `-f` the runtime refuses,
/// which surfaces as [`Teardown::Failed`] and makes the gate report the
/// worktree as held, which is the fail-closed answer it already wants.
///
/// Do not reach for this anywhere else: [`Self::discard`]'s
/// invoke-unconditionally invariant (#2596) still holds for every other
/// removal path.
pub fn discard_if_stopped(&self) -> Teardown {
classify_removal(self.remove(false))
}
/// Probe this container's running state, preserving the difference
/// between a definitive "not running" and a transient inspection failure.
///
/// Prefer this over `is_running().unwrap_or(false)` at any call site
/// where the returned boolean gates a mutation on the container being
/// stopped: `unwrap_or(false)` swallows a transient `docker inspect`
/// failure into a false negative and lets the gate open against a
/// possibly-live container: the swallowing-existence-probe class of
/// bug fixed on the removal path in #2596.
pub fn probe_running(&self) -> Probe {
classify_running_probe(self.is_running())
}
pub fn exec_command(&self, options: Option<&str>, cmd: &str) -> String {
self.runtime.exec_command(&self.name, options, cmd)
}
/// Argv that runs `cmd` inside this container, for a caller that spawns and
/// bounds the process itself. See [`ContainerRuntime::build_exec_argv`].
pub fn build_exec_argv(&self, workdir: &str, cmd: &[String]) -> Vec<String> {
self.runtime.build_exec_argv(&self.name, workdir, cmd)
}
#[tracing::instrument(target = "containers.exec", skip_all, fields(name = %self.name, cmd = ?cmd))]
pub fn exec(&self, cmd: &[&str]) -> Result<std::process::Output> {
let result = self.runtime.exec(&self.name, cmd);
match &result {
Ok(out) => tracing::debug!(
target: "containers.exec",
status = ?out.status,
stdout_bytes = out.stdout.len(),
stderr_bytes = out.stderr.len(),
"exec completed",
),
Err(e) => tracing::warn!(target: "containers.exec", error = %e, "exec failed"),
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_ok_is_removed() {
assert!(matches!(classify_removal(Ok(())), Teardown::Removed));
}
#[test]
fn classify_not_found_is_already_gone() {
let r = Err(error::DockerError::ContainerNotFound(
"aoe-sandbox-x".into(),
));
assert!(matches!(classify_removal(r), Teardown::AlreadyGone));
}
#[test]
fn classify_other_error_is_failed() {
let r = Err(error::DockerError::RemoveFailed("daemon busy".into()));
assert!(matches!(classify_removal(r), Teardown::Failed(_)));
}
#[test]
fn probe_ok_true_is_running() {
assert!(matches!(classify_running_probe(Ok(true)), Probe::Running));
}
#[test]
fn probe_ok_false_is_not_running() {
assert!(matches!(
classify_running_probe(Ok(false)),
Probe::NotRunning
));
}
#[test]
fn probe_err_is_unknown() {
let r = Err(error::DockerError::InspectFailed("inspect exit 1".into()));
assert!(matches!(classify_running_probe(r), Probe::Unknown(_)));
}
#[test]
fn test_container_generate_name_short_id() {
let name = DockerContainer::generate_name("abc");
assert_eq!(name, "aoe-sandbox-abc");
}
#[test]
fn test_container_generate_name_long_id() {
let name = DockerContainer::generate_name("abcdefghijklmnop");
assert_eq!(name, "aoe-sandbox-abcdefgh");
}
#[test]
fn test_container_exec_command() {
let mut container = DockerContainer::new("test1234567890ab", "ubuntu:latest");
container.runtime = ContainerRuntime::docker();
let cmd = container.exec_command(None, "my-agent");
assert_eq!(cmd, "docker exec -it aoe-sandbox-test1234 my-agent");
}
#[test]
fn test_anonymous_volumes_in_create_args() {
let container = DockerContainer::new("test1234567890ab", "alpine:latest");
let config = ContainerConfig {
working_dir: "/workspace/myproject".to_string(),
volumes: vec![],
anonymous_volumes: vec![
"/workspace/myproject/target".to_string(),
"/workspace/myproject/node_modules".to_string(),
],
named_ignore_volumes: vec![],
environment: vec![],
cpu_limit: None,
memory_limit: None,
port_mappings: vec![],
..Default::default()
};
let args = container.build_create_args(&config);
// Find the anonymous volume flags
let v_positions: Vec<usize> = args
.iter()
.enumerate()
.filter(|(_, a)| *a == "-v")
.map(|(i, _)| i)
.collect();
let volume_values: Vec<&str> = v_positions.iter().map(|&i| args[i + 1].as_str()).collect();
assert!(volume_values.contains(&"/workspace/myproject/target"));
assert!(volume_values.contains(&"/workspace/myproject/node_modules"));
}
#[test]
fn test_no_anonymous_volumes_when_empty() {
let container = DockerContainer::new("test1234567890ab", "alpine:latest");
let config = ContainerConfig {
working_dir: "/workspace".to_string(),
volumes: vec![],
anonymous_volumes: vec![],
named_ignore_volumes: vec![],
environment: vec![],
cpu_limit: None,
memory_limit: None,
port_mappings: vec![],
..Default::default()
};
let args = container.build_create_args(&config);
// No -v flags at all
assert!(!args.contains(&"-v".to_string()));
}
}
-812
View File
@@ -1,812 +0,0 @@
//! The unified `ContainerRuntime`. Shared behavior lives on `RuntimeBase`;
//! this impl dispatches the four genuinely runtime-specific operations
//! (existence probe, running-state probe, exec-command formatting, and
//! batch status query) on a `RuntimeKind` discriminant.
use std::collections::HashMap;
use serde_json::Value;
use super::container_interface::ContainerConfig;
use super::error::{DockerError, Result};
use super::runtime_base::RuntimeBase;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuntimeKind {
Docker,
AppleContainer,
Podman,
}
pub struct ContainerRuntime {
pub(crate) base: RuntimeBase,
pub(crate) kind: RuntimeKind,
}
fn is_runtime_timeout(error: &DockerError) -> bool {
matches!(
error,
DockerError::IoError(error) if error.kind() == std::io::ErrorKind::TimedOut
)
}
impl ContainerRuntime {
pub fn docker() -> Self {
Self {
base: RuntimeBase::DOCKER,
kind: RuntimeKind::Docker,
}
}
pub fn apple_container() -> Self {
Self {
base: RuntimeBase::APPLE_CONTAINER,
kind: RuntimeKind::AppleContainer,
}
}
pub fn podman() -> Self {
Self {
base: RuntimeBase::PODMAN,
kind: RuntimeKind::Podman,
}
}
}
impl Default for ContainerRuntime {
fn default() -> Self {
Self::docker()
}
}
impl ContainerRuntime {
pub fn is_available(&self) -> bool {
self.base.is_available()
}
pub fn is_daemon_running(&self) -> bool {
self.base.is_daemon_running()
}
pub fn image_exists_locally(&self, image: &str) -> bool {
self.base.image_exists_locally(image)
}
pub fn local_image_digest(&self, image: &str) -> Option<String> {
match self.kind {
RuntimeKind::Docker | RuntimeKind::Podman => {
// `RepoDigests` holds `repo@sha256:...` entries for the pulled
// manifest. One subprocess, newline-joined so a multi-registry
// image still lets us pick the entry matching this reference.
let mut cmd = self.base.command();
cmd.args([
"image",
"inspect",
"--format",
"{{range .RepoDigests}}{{println .}}{{end}}",
image,
]);
let output = self.base.probe_output(&mut cmd).ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
super::image_update::pick_repo_digest(image, &stdout)
}
// Apple Container's `image inspect` doesn't expose a Docker-style
// repo digest; skip the staleness check there rather than guess.
RuntimeKind::AppleContainer => None,
}
}
pub fn pull_image(&self, image: &str) -> Result<()> {
self.base.pull_image(image)
}
pub fn ensure_image(&self, image: &str) -> Result<()> {
self.base.ensure_image(image)
}
pub fn default_sandbox_image(&self) -> &'static str {
self.base.default_sandbox_image()
}
pub fn effective_default_image(&self) -> String {
self.base.effective_default_image()
}
pub fn does_container_exist(&self, name: &str) -> Result<bool> {
match self.kind {
RuntimeKind::Docker | RuntimeKind::Podman => {
// `container inspect` (not `docker inspect`): pins the stderr
// wording DOCKER_MISSING captures in the runtime_base tests,
// so is_not_found classifies "absent" cleanly. Changing this
// argv or the per-runtime not_found / daemon_down /
// permission_denied markers without new fixtures silently
// breaks the classifier. See is_container_running above and
// the pinning comment at #2596 / #2652.
let mut cmd = self.base.command();
cmd.args(["container", "inspect", name]);
let output = self.base.probe_output(&mut cmd)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return self.base.classify_probe_failure(&stderr);
}
Ok(true)
}
RuntimeKind::AppleContainer => {
// Apple Container's `inspect` returns success(0) for
// non-existent containers, so we use `logs` which properly
// fails for missing containers. APPLE_MISSING captures
// Apple's absent-container stderr (from `rm/delete`);
// not_found_markers / daemon_down_markers /
// permission_denied_markers on RuntimeBase::APPLE_CONTAINER
// key off Apple's not-found style and are expected to match
// `logs` stderr by substring, though `logs` stderr has not
// been captured as a fixture. Switching argv here (or
// tightening the markers) needs new fixtures. Same
// silent-break risk as the Docker/Podman pinning comment
// above. See #2596.
// TODO: verify Apple `container logs` semantics on
// stopped-but-existing containers (cf. #2730 for
// fixture capture).
let mut cmd = self.base.command();
cmd.args(["logs", name]);
let output = self.base.probe_output(&mut cmd)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return self.base.classify_probe_failure(&stderr);
}
Ok(true)
}
}
}
pub fn is_container_running(&self, name: &str) -> Result<bool> {
match self.kind {
RuntimeKind::Docker | RuntimeKind::Podman => {
// `container inspect` (not the shorter `docker inspect`): the two
// subcommands emit different stderr for a missing container
// ("No such container" vs "No such object"), and DOCKER_MISSING
// in the runtime_base tests pins the former. Changing this argv
// silently breaks is_not_found classification. See #2596.
let mut cmd = self.base.command();
cmd.args(["container", "inspect", "-f", "{{.State.Running}}", name]);
let output = self.base.probe_output(&mut cmd)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return self.base.classify_probe_failure(&stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.trim() == "true")
}
RuntimeKind::AppleContainer => {
// Apple's `container inspect` is the only inspect subcommand
// (no `container container inspect`), and its stderr wording
// is pinned in RuntimeBase::APPLE_CONTAINER.not_found_markers:
// it covers both the inspect-specific shape
// (`container not found: <name>`) and the logs/delete shape
// (`container with ID <id> not found`). The classifier
// separately checks daemon_down_markers. Do not tighten this
// argv or either marker list without capturing new
// fixtures; same silent-break risk as the Docker/Podman
// comment above. See #2596.
let mut cmd = self.base.command();
cmd.args(["inspect", name]);
let output = self.base.probe_output(&mut cmd)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return self.base.classify_probe_failure(&stderr);
}
let out_json: Value = serde_json::from_slice(&output.stdout)
// serde_json::Error::Display is single-line by construction
// (format is "<code> at line N column M"); no sanitize_stderr
// wrapping needed to preserve the single-line convention.
.map_err(|e| DockerError::InspectFailed(e.to_string()))?;
Self::apple_container_inspect_state(&out_json).map(|state| state == "running")
}
}
}
/// The runtime state string from Apple `container inspect` JSON.
///
/// The CLI serialised `/0/status` as a bare string through 0.12.x; the
/// 1.0.0 ManagedResource cleanup nested it as an object whose `state`
/// field carries the same value (#3239). Both shapes are supported
/// because either CLI generation may be installed. Any other shape is
/// still Err, never Ok(false): a silently unparsed status would route to
/// Probe::NotRunning, the exact fail-open swallowing-existence-probe
/// hole (#2596) that the previous string-only guard existed to close.
fn apple_container_inspect_state(out_json: &Value) -> Result<&str> {
let Some(status) = out_json.pointer("/0/status") else {
return Err(DockerError::InspectFailed(
"apple container inspect: exit 0 but no /0/status in output".into(),
));
};
status
.as_str()
.or_else(|| status.pointer("/state").and_then(Value::as_str))
.ok_or_else(|| {
DockerError::InspectFailed(
"apple container inspect: /0/status is neither a string nor \
an object with a string `state`"
.into(),
)
})
}
/// Read the runtime's authoritative container identifier after a create
/// command whose client-side timeout made its result indeterminate.
fn inspect_container_id(&self, name: &str) -> Result<String> {
let mut cmd = self.base.command();
match self.kind {
RuntimeKind::Docker | RuntimeKind::Podman => {
cmd.args(["container", "inspect", "-f", "{{.Id}}", name]);
}
RuntimeKind::AppleContainer => {
cmd.args(["inspect", name]);
}
}
let output = self.base.probe_output(&mut cmd)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
self.base.classify_probe_failure(&stderr)?;
return Err(DockerError::ContainerNotFound(name.to_string()));
}
let id = match self.kind {
RuntimeKind::Docker | RuntimeKind::Podman => {
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
RuntimeKind::AppleContainer => {
let payload: Value = serde_json::from_slice(&output.stdout)
.map_err(|error| DockerError::InspectFailed(error.to_string()))?;
Self::apple_container_inspect_id(&payload)?.to_string()
}
};
if id.is_empty() {
return Err(DockerError::InspectFailed(
"container inspect returned an empty id".to_string(),
));
}
Ok(id)
}
fn apple_container_inspect_id(payload: &Value) -> Result<&str> {
payload
.pointer("/0/id")
.or_else(|| payload.pointer("/0/configuration/id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|id| !id.is_empty())
.ok_or_else(|| {
DockerError::InspectFailed(
"apple container inspect: no non-empty /0/id or /0/configuration/id"
.to_string(),
)
})
}
/// The container's configured working directory (`Config.WorkingDir`), or
/// `None` if it can't be determined (container gone, inspect failed, or the
/// field is empty). Used to backfill the create-time-pinned workdir for
/// sandbox sessions that predate it (#2414). Works on stopped containers
/// too, since `inspect` reads static config.
///
/// Apple's `container` CLI does not expose this via a stable `inspect`
/// field we rely on, so it returns `None` there and the caller keeps the
/// create-time value (or the live fallback for legacy sessions).
pub fn container_working_dir(&self, name: &str) -> Option<String> {
if !matches!(self.kind, RuntimeKind::Docker | RuntimeKind::Podman) {
return None;
}
let mut cmd = self.base.command();
cmd.args(["container", "inspect", "-f", "{{.Config.WorkingDir}}", name]);
let output = self.base.probe_output(&mut cmd).ok()?;
if !output.status.success() {
return None;
}
let wd = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!wd.is_empty()).then_some(wd)
}
pub fn build_create_args(
&self,
name: &str,
image: &str,
config: &ContainerConfig,
) -> Vec<String> {
self.base.build_create_args(name, image, config)
}
pub fn create_container(
&self,
name: &str,
image: &str,
config: &ContainerConfig,
) -> Result<String> {
if self.does_container_exist(name)? {
return Err(DockerError::ContainerAlreadyExists(name.to_string()));
}
match self.base.run_create(name, image, config) {
Err(error) if is_runtime_timeout(&error) => match self.inspect_container_id(name) {
Ok(id) => Ok(id),
Err(_) => Err(error),
},
result => result,
}
}
pub fn start_container(&self, name: &str) -> Result<()> {
match self.base.start_container(name) {
Err(error) if is_runtime_timeout(&error) => match self.is_container_running(name) {
Ok(true) => Ok(()),
_ => Err(error),
},
result => result,
}
}
pub fn stop_container(&self, name: &str) -> Result<()> {
match self.base.stop_container(name) {
Err(error) if is_runtime_timeout(&error) => match self.is_container_running(name) {
Ok(false) => Ok(()),
_ => Err(error),
},
result => result,
}
}
pub fn remove(&self, name: &str, force: bool) -> Result<()> {
match self.base.remove(name, force) {
Err(error) if is_runtime_timeout(&error) => match self.does_container_exist(name) {
Ok(false) => Ok(()),
_ => Err(error),
},
result => result,
}
}
pub fn exec_command(&self, name: &str, options: Option<&str>, cmd: &str) -> String {
match self.kind {
RuntimeKind::Docker | RuntimeKind::Podman => {
// Docker/Podman containers inherit a full PATH, so the command
// can be appended directly without wrapping in `sh -c`.
self.base.exec_command(name, options, cmd)
}
RuntimeKind::AppleContainer => {
// Apple Container has a very limited initial PATH, so we wrap
// the command in `sh -c` to get a proper shell environment.
// Single-quote with escaped embedded quotes to avoid issues
// with double-quote metacharacters ($, `, \, !) in the command.
let escaped = cmd.replace('\'', "'\\''");
let cmd_str = format!("'{}'", escaped);
if let Some(opt_str) = options {
[
"container",
"exec",
"-it",
opt_str,
name,
"sh",
"-c",
&cmd_str,
]
.join(" ")
} else {
["container", "exec", "-it", name, "sh", "-c", &cmd_str].join(" ")
}
}
}
}
/// Argv for a non-interactive `exec` of `cmd` in `name`, spawned by the
/// caller, which keeps its own timeout and output capture.
///
/// Unlike the shell string [`Self::exec_command`] builds for the tmux pane:
/// no `-it`, because the caller pipes stdout with stdin closed, and argv
/// rather than a shell string, so an untrusted argument is never
/// shell-parsed. An empty `workdir` omits `-w`.
pub fn build_exec_argv(&self, name: &str, workdir: &str, cmd: &[String]) -> Vec<String> {
match self.kind {
RuntimeKind::Docker | RuntimeKind::Podman => {
self.base.build_exec_argv(name, workdir, cmd)
}
RuntimeKind::AppleContainer => {
// Same limited-PATH problem `exec_command` wraps for, but the
// command cannot be spliced into the shell string here: `cmd`
// carries untrusted text. `exec "$@"` re-execs the arguments the
// shell received positionally, so the shell only supplies PATH
// and every element survives verbatim. The `sh` after the script
// is `$0`; the command starts at `$1`.
let mut argv = self.base.build_exec_argv(name, workdir, &[]);
argv.extend([
"sh".to_string(),
"-c".to_string(),
"exec \"$@\"".to_string(),
"sh".to_string(),
]);
argv.extend(cmd.iter().cloned());
argv
}
}
}
pub fn exec(&self, name: &str, cmd: &[&str]) -> Result<std::process::Output> {
self.base.exec(name, cmd)
}
pub fn batch_running_states(&self, prefix: &str) -> HashMap<String, bool> {
match self.kind {
RuntimeKind::Docker | RuntimeKind::Podman => {
let mut cmd = self.base.command();
cmd.args([
"ps",
"-a",
"--filter",
&format!("name={}", prefix),
"--format",
"{{.Names}}\t{{.State}}",
]);
let output = match self.base.probe_output(&mut cmd) {
Ok(output) if output.status.success() => output,
_ => return HashMap::new(),
};
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.lines()
.filter_map(|line| {
let mut parts = line.splitn(2, '\t');
let name = parts.next()?.trim();
let state = parts.next()?.trim();
// Docker/Podman's --filter name= does substring matching, so
// post-filter to ensure we only include exact prefix matches.
if name.is_empty() || !name.starts_with(prefix) {
return None;
}
Some((name.to_string(), state == "running"))
})
.collect()
}
RuntimeKind::AppleContainer => {
let _ = prefix;
HashMap::new()
}
}
}
/// Resource usage of every running container named with `prefix`, in a
/// single subprocess call.
///
/// `--no-stream` still costs seconds, because the runtime samples every
/// running container twice to produce a CPU delta; callers go through
/// [`super::stats::cached_stats`] rather than calling this on a UI cadence.
pub fn batch_stats(&self, prefix: &str) -> super::stats::StatsMap {
match self.kind {
RuntimeKind::Docker | RuntimeKind::Podman => {
// Stats everything and prefix-filters the rows, unlike
// `batch_running_states`, which narrows with `--filter name=`.
// The asymmetry is deliberate: `stats` has no `--filter`, and
// naming containers positionally fails the whole call with
// "No such container" if any one of them is gone, which is a
// normal state for a session whose sandbox has stopped.
let mut cmd = self.base.command();
cmd.args([
"stats",
"--no-stream",
"--format",
// A literal tab, not the `\t` escape: the argv reaches
// the runtime without a shell, and Go template text is
// emitted verbatim either way.
"{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.PIDs}}",
]);
let output = match self.base.probe_output(&mut cmd) {
Ok(output) if output.status.success() => output,
_ => return super::stats::StatsMap::new(),
};
super::stats::parse_stats_output(&String::from_utf8_lossy(&output.stdout), prefix)
}
// Apple's `container` CLI has no stats subcommand, so a sandbox on
// that runtime reports unknown rather than a fabricated number.
RuntimeKind::AppleContainer => {
let _ = prefix;
super::stats::StatsMap::new()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn docker_if_available() -> Option<ContainerRuntime> {
let rt = ContainerRuntime::docker();
if !rt.is_available() || !rt.is_daemon_running() {
None
} else {
Some(rt)
}
}
fn apple_container_if_available() -> Option<ContainerRuntime> {
let rt = ContainerRuntime::apple_container();
if !rt.is_available() || !rt.is_daemon_running() {
None
} else {
Some(rt)
}
}
fn podman_if_available() -> Option<ContainerRuntime> {
let rt = ContainerRuntime::podman();
if !rt.is_available() || !rt.is_daemon_running() {
None
} else {
Some(rt)
}
}
// Pulls `hello-world` from a live registry, so it flakes on a transient
// pull failure or network hang in CI. Per the Docker-test convention,
// gate it behind `#[ignore]` so it only runs when explicitly requested.
#[test]
#[ignore = "pulls hello-world from a live registry; run with --ignored"]
fn test_image_exists_locally_with_common_image() {
for rt in [
docker_if_available(),
apple_container_if_available(),
podman_if_available(),
]
.into_iter()
.flatten()
{
rt.pull_image("hello-world").unwrap();
assert!(rt.image_exists_locally("hello-world"));
}
}
#[test]
fn test_image_exists_locally_nonexistent() {
for rt in [
docker_if_available(),
apple_container_if_available(),
podman_if_available(),
]
.into_iter()
.flatten()
{
assert!(!rt.image_exists_locally("nonexistent-image-that-does-not-exist:v999"));
}
}
// Pulls `hello-world` from a live registry; same flake risk as
// `test_image_exists_locally_with_common_image`, so gate it the same way.
#[test]
#[ignore = "pulls hello-world from a live registry; run with --ignored"]
fn test_ensure_image_uses_local_image() {
for rt in [
docker_if_available(),
apple_container_if_available(),
podman_if_available(),
]
.into_iter()
.flatten()
{
rt.pull_image("hello-world").unwrap();
assert!(rt.ensure_image("hello-world").is_ok());
}
}
#[test]
fn test_ensure_image_fails_for_nonexistent_remote() {
for rt in [
docker_if_available(),
apple_container_if_available(),
podman_if_available(),
]
.into_iter()
.flatten()
{
assert!(rt
.ensure_image("nonexistent-image-that-does-not-exist:v999")
.is_err());
}
}
#[test]
fn test_apple_container_inspect_shapes() {
use serde_json::json;
// Both CLI generations (#3239) plus the drift shapes that must stay
// Err so lifecycle gates fail closed rather than fail open (#2596).
let cases = [
(json!([{"status": "running"}]), Some("running")),
(json!([{"status": "stopped"}]), Some("stopped")),
// 1.0.0 ManagedResource shape, as emitted by container CLI 1.2.0
(
json!([{"status": {"state": "running", "networks": [], "startedDate": "2026-08-04T10:36:08Z"}}]),
Some("running"),
),
(json!([{"status": {"state": "stopped"}}]), Some("stopped")),
// object without a string `state`
(json!([{"status": {"state": 3}}]), None),
(json!([{"status": {"phase": "running"}}]), None),
// neither shape
(json!([{"status": 3}]), None),
(json!([{"status": null}]), None),
// missing entirely
(json!([{}]), None),
(json!([]), None),
];
for (payload, expected) in cases {
let result = ContainerRuntime::apple_container_inspect_state(&payload);
match expected {
Some(state) => {
let parsed = result.unwrap_or_else(|e| panic!("{payload}: {e}"));
assert_eq!(parsed, state);
}
None => assert!(result.is_err(), "expected Err for {payload}"),
}
}
let id_cases = [
(json!([{"id": "new-id"}]), Some("new-id")),
(
json!([{"configuration": {"id": "legacy-id"}}]),
Some("legacy-id"),
),
(json!([{"id": ""}]), None),
(json!([{"id": " trimmed-id "}]), Some("trimmed-id")),
(json!([{"id": " "}]), None),
(json!([{"id": 3}]), None),
(json!([{}]), None),
(json!([]), None),
];
for (payload, expected) in id_cases {
let result = ContainerRuntime::apple_container_inspect_id(&payload);
match expected {
Some(id) => assert_eq!(
result.unwrap_or_else(|error| panic!("{payload}: {error}")),
id
),
None => assert!(result.is_err(), "expected Err for {payload}"),
}
}
}
#[test]
fn test_podman_runtime_uses_podman_binary() {
let rt = ContainerRuntime::podman();
assert_eq!(rt.kind, RuntimeKind::Podman);
assert_eq!(rt.base.binary, "podman");
assert_eq!(rt.base.name, "Podman");
}
#[test]
fn test_podman_supports_docker_compatible_features() {
// Podman is a drop-in for Docker, so it must support the same feature
// set the shared base relies on. If this regresses, the create-args
// builder will silently produce broken output for podman users.
let rt = ContainerRuntime::podman();
assert!(rt.base.supports_read_only_volumes);
assert!(rt.base.supports_remove_volumes);
assert!(rt.base.supports_named_volumes);
assert_eq!(rt.base.remove_subcommand, "rm");
assert_eq!(rt.base.pull_prefix, &["pull"]);
}
#[test]
fn test_podman_exec_command_format_matches_docker() {
// The CLI surfaces this string to the user via tmux; it must not
// wrap the command in `sh -c` the way Apple Container does.
let rt = ContainerRuntime::podman();
let cmd = rt.exec_command("aoe-sandbox-test1234", None, "claude");
assert_eq!(cmd, "podman exec -it aoe-sandbox-test1234 claude");
}
/// A title one-shot argv: the agent, its flags, and a prompt full of shell
/// metacharacters as ONE element.
fn oneshot_argv() -> Vec<String> {
[
"claude",
"-p",
"--model",
"haiku",
"name this: `rm -rf /` $(id) \"quoted\" 'single'\nsecond line",
]
.iter()
.map(|s| s.to_string())
.collect()
}
#[test]
fn build_exec_argv_docker_is_non_interactive_and_sets_workdir() {
let rt = ContainerRuntime::docker();
let argv = rt.build_exec_argv("aoe-sandbox-test1234", "/workspace", &oneshot_argv());
let mut expected = vec![
"docker".to_string(),
"exec".to_string(),
"-w".to_string(),
"/workspace".to_string(),
"aoe-sandbox-test1234".to_string(),
];
expected.extend(oneshot_argv());
assert_eq!(argv, expected);
}
#[test]
fn build_exec_argv_podman_matches_docker_shape() {
let argv = ContainerRuntime::podman().build_exec_argv(
"aoe-sandbox-test1234",
"/workspace",
&oneshot_argv(),
);
assert_eq!(
&argv[..5],
&["podman", "exec", "-w", "/workspace", "aoe-sandbox-test1234"]
);
assert_eq!(&argv[5..], &oneshot_argv()[..]);
}
#[test]
fn build_exec_argv_apple_container_wraps_for_path_without_splicing() {
let argv = ContainerRuntime::apple_container().build_exec_argv(
"aoe-sandbox-test1234",
"/workspace",
&oneshot_argv(),
);
assert_eq!(
&argv[..9],
&[
"container",
"exec",
"-w",
"/workspace",
"aoe-sandbox-test1234",
"sh",
"-c",
"exec \"$@\"",
"sh",
]
);
// The shell program is a fixed literal and the command follows it as
// separate elements, so the prompt is never shell-parsed.
assert_eq!(&argv[9..], &oneshot_argv()[..]);
}
#[test]
fn build_exec_argv_never_requests_a_tty() {
// A TTY request against the piped, stdin-closed child smart rename
// spawns would fail or hang, unlike the tmux `exec_command` path.
for rt in [
ContainerRuntime::docker(),
ContainerRuntime::podman(),
ContainerRuntime::apple_container(),
] {
let argv = rt.build_exec_argv("aoe-sandbox-test1234", "/workspace", &oneshot_argv());
assert!(
!argv.iter().any(|a| a == "-it" || a == "-i" || a == "-t"),
"{:?} requested a tty: {argv:?}",
rt.kind
);
}
}
#[test]
fn build_exec_argv_omits_workdir_when_empty() {
let argv = ContainerRuntime::docker().build_exec_argv(
"aoe-sandbox-test1234",
"",
&["claude".to_string()],
);
assert_eq!(argv, ["docker", "exec", "aoe-sandbox-test1234", "claude"]);
}
}
File diff suppressed because it is too large Load Diff
-219
View File
@@ -1,219 +0,0 @@
//! Container resource sampling for the TUI system-health table.
//!
//! A sandbox session's tmux pane holds a `docker exec` client, not the agent:
//! the agent runs inside the container, off the pane's host process tree
//! entirely (its own cgroup on Linux, a different VM on macOS). Walking the
//! pane pid therefore measures the CLI client, so sandbox rows read the
//! runtime's own accounting instead.
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
/// One container's resource usage, as the runtime reports it.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct ContainerStats {
/// Percent of a single core, matching `docker stats`: 100.0 is one core
/// saturated, so a container busy on four cores reads 400.
pub cpu_percent: f64,
pub mem_used_bytes: u64,
pub pids: usize,
}
pub type StatsMap = HashMap<String, ContainerStats>;
/// `docker stats --no-stream` re-samples every running container to compute a
/// CPU delta, which costs ~2s on a host running a couple dozen sandboxes. That
/// cannot ride the 1s metrics cadence, so the refresh runs on its own thread
/// and callers read whatever the last completed pass left behind.
const STATS_TTL: Duration = Duration::from_secs(5);
struct Cache {
map: Arc<StatsMap>,
fetched_at: Option<Instant>,
}
/// The cache guard, recovering from poisoning rather than propagating it. The
/// data behind the lock is a plain map with no invariant a panicking writer
/// could have half-broken, and treating a poisoned lock as fatal would strand
/// every later sandbox row on "?" for the rest of the process.
fn cache() -> std::sync::MutexGuard<'static, Cache> {
static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
CACHE
.get_or_init(|| {
Mutex::new(Cache {
map: Arc::default(),
fetched_at: None,
})
})
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// Set while a refresh thread is in flight, so a caller polling every second
/// never stacks up a second `docker stats` behind a slow one.
static REFRESHING: AtomicBool = AtomicBool::new(false);
/// Clears [`REFRESHING`] on drop, so a refresh that panics does not latch the
/// flag and silently block every later refresh.
struct RefreshGuard;
impl Drop for RefreshGuard {
fn drop(&mut self) {
REFRESHING.store(false, Ordering::SeqCst);
}
}
/// The most recent sandbox container stats, starting a background refresh when
/// the cached map has aged past `STATS_TTL`.
///
/// Never blocks. The first call after the pane opens returns an empty map and
/// the row reads "?" until the refresh lands; that beats stalling the host
/// CPU/memory sample behind a multi-second subprocess.
pub fn cached_stats() -> Arc<StatsMap> {
let (map, stale) = {
let cache = cache();
(
cache.map.clone(),
cache.fetched_at.is_none_or(|at| at.elapsed() >= STATS_TTL),
)
};
if stale && !REFRESHING.swap(true, Ordering::SeqCst) {
let spawned = std::thread::Builder::new()
.name("aoe-container-stats".to_string())
.spawn(|| {
let _guard = RefreshGuard;
let fresh = super::batch_container_stats();
let mut cache = cache();
cache.map = Arc::new(fresh);
cache.fetched_at = Some(Instant::now());
});
if spawned.is_err() {
REFRESHING.store(false, Ordering::SeqCst);
}
}
map
}
/// Parse the rows of `<runtime> stats --no-stream --format
/// "{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.PIDs}}"`, keeping only the
/// containers named with `prefix`.
pub(crate) fn parse_stats_output(stdout: &str, prefix: &str) -> StatsMap {
stdout
.lines()
.filter_map(parse_stats_line)
.filter(|(name, _)| name.starts_with(prefix))
.collect()
}
/// One tab-separated stats row. A container that is restarting reports `--`
/// for the figures it has no sample for; those rows are dropped rather than
/// read as a real zero.
fn parse_stats_line(line: &str) -> Option<(String, ContainerStats)> {
let mut parts = line.split('\t');
let name = parts.next()?.trim();
let cpu = parts.next()?.trim();
let mem = parts.next()?.trim();
let pids = parts.next()?.trim();
if name.is_empty() {
return None;
}
let cpu_percent = cpu.strip_suffix('%')?.trim().parse().ok()?;
// MemUsage is "used / limit"; the limit is the container's cap, which no
// health surface shows today, so only the used side is kept.
let mem_used_bytes = parse_size(mem.split('/').next()?)?;
Some((
name.to_string(),
ContainerStats {
cpu_percent,
mem_used_bytes,
pids: pids.parse().ok()?,
},
))
}
/// A runtime-formatted byte size. Docker prints binary units (`3.085GiB`),
/// podman decimal ones (`1.045MB`), so both suffix families are accepted.
fn parse_size(text: &str) -> Option<u64> {
let text = text.trim();
let unit_at = text.find(|c: char| c.is_ascii_alphabetic())?;
let (value, unit) = text.split_at(unit_at);
let value: f64 = value.trim().parse().ok()?;
let multiplier: f64 = match unit.trim() {
"B" => 1.0,
"kB" | "KB" => 1e3,
"MB" => 1e6,
"GB" => 1e9,
"TB" => 1e12,
"KiB" | "kiB" => 1024.0,
"MiB" => 1024f64.powi(2),
"GiB" => 1024f64.powi(3),
"TiB" => 1024f64.powi(4),
_ => return None,
};
(value >= 0.0).then(|| (value * multiplier).round() as u64)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_size_accepts_both_unit_families() {
let cases = [
("0B", Some(0)),
("512B", Some(512)),
("1022MiB", Some(1022 * (1 << 20))),
("450.4MiB", Some(472_278_630)),
("3.085GiB", Some(3_312_493_527)),
("20GiB", Some(20 * (1 << 30))),
// podman's decimal units must not be read as binary ones.
("1.045MB", Some(1_045_000)),
("16.62GB", Some(16_620_000_000)),
("--", None),
("12", None),
("MiB", None),
("1.2Xb", None),
];
for (text, expected) in cases {
assert_eq!(parse_size(text), expected, "size {text:?}");
}
}
#[test]
fn parse_stats_line_reads_a_docker_row() {
let (name, stats) =
parse_stats_line("aoe-sandbox-32f0940b\t82.30%\t3.085GiB / 20GiB\t36").expect("row");
assert_eq!(name, "aoe-sandbox-32f0940b");
assert_eq!(stats.cpu_percent, 82.30);
assert_eq!(stats.mem_used_bytes, 3_312_493_527);
assert_eq!(stats.pids, 36);
}
#[test]
fn parse_stats_line_rejects_unusable_rows() {
let cases = [
// A restarting container reports no sample; a zero would read as
// a live, idle container.
"aoe-sandbox-a\t--\t-- / --\t0",
"aoe-sandbox-a\t12.0\t1MiB / 2MiB\t3", // CPU missing its percent
"aoe-sandbox-a\t12.0%\t1MiB / 2MiB", // truncated row
"\t12.0%\t1MiB / 2MiB\t3", // no name
"",
];
for line in cases {
assert!(parse_stats_line(line).is_none(), "line {line:?}");
}
}
#[test]
fn parse_stats_output_keeps_only_prefixed_containers() {
let stdout = "aoe-sandbox-a1\t1.00%\t100MiB / 20GiB\t7\n\
clawbolt-pg\t0.00%\t75.01MiB / 30.56GiB\t6\n\
not-aoe-sandbox-a2\t2.00%\t200MiB / 20GiB\t8\n";
let map = parse_stats_output(stdout, "aoe-sandbox-");
assert_eq!(map.keys().collect::<Vec<_>>(), ["aoe-sandbox-a1"]);
assert_eq!(map["aoe-sandbox-a1"].pids, 7);
}
}
+6 -430
View File
@@ -2,7 +2,6 @@
use std::path::{Path, PathBuf};
use crate::containers::DockerContainer;
use crate::session::Instance;
use super::open_repo_at;
@@ -430,76 +429,11 @@ pub fn is_permission_error(error: &str) -> bool {
|| lower.contains("access is denied")
}
/// Delete worktree contents from inside the sandbox container.
///
/// Starts the container if it exists but is stopped, then runs
/// `find . -mindepth 1 -delete` to remove all contents (including
/// root-owned files that the host user cannot delete directly).
///
/// The existence probe is fail-open with a `warn!`: on a transient runtime
/// failure (daemon down, permission denied, or any other non-success stderr
/// classified by `RuntimeBase::classify_probe_failure`) the cleanup is
/// skipped and the failure surfaces in logs. The running-state probe warns
/// on failure via `classify_probe_failure` and attempts `container.start()`
/// (idempotent when running); cleanup only skips if the start itself fails.
///
/// Collapsing the existence probe to `unwrap_or(false)` re-introduces the
/// swallowing-existence-probe class of bug (#2596 / #2652 / #2654).
///
/// Returns true if the container successfully deleted the contents.
pub fn cleanup_sandbox_worktree(instance: &Instance) -> bool {
let container = DockerContainer::from_session_id(&instance.id);
match container.exists() {
Ok(true) => {}
Ok(false) => return false,
Err(e) => {
tracing::warn!(
target: "containers.runtime",
session = %instance.id,
error = %e,
"container existence probe failed during worktree cleanup; skipping best-effort cleanup"
);
return false;
}
}
let needs_start = match container.probe_running() {
crate::containers::Probe::Running => false,
crate::containers::Probe::NotRunning => true,
crate::containers::Probe::Unknown(e) => {
tracing::warn!(
target: "containers.runtime",
session = %instance.id,
error = %e,
"container running-state probe failed during worktree cleanup; attempting container start (safe if already running)"
);
true
}
};
if needs_start && container.start().is_err() {
return false;
}
match container.exec(&["find", ".", "-mindepth", "1", "-delete"]) {
Ok(output) => output.status.success(),
Err(_) => false,
}
}
/// Perform full worktree cleanup with automatic sandbox fallback.
/// Perform full worktree cleanup.
///
/// Handles both cases:
/// - `.git` file missing: removes directory and prunes stale references
/// - `.git` file present: uses `git worktree remove`, falls back to
/// container cleanup for sandboxed sessions with permission errors
///
/// `allow_container_removal` controls whether the sandbox fallback is
/// permitted to force-remove the container as part of its recovery.
/// Set to `true` when the caller has already requested container
/// deletion (or is about to), and `false` when the caller wants to
/// preserve the container (e.g. `aoe remove --keep-container` or
/// `delete_worktree=true, delete_sandbox=false`). When `false` and
/// the fallback is the only way to make progress, the worktree
/// removal fails with the original permission error instead of
/// quietly tearing down the container behind the user's back.
/// - `.git` file present: uses `git worktree remove`
///
/// Returns `Ok(())` if the worktree was successfully removed, or
/// `Err(errors)` with error messages on failure.
@@ -507,9 +441,8 @@ pub fn remove_managed_worktree(
git_wt: &GitWorktree,
worktree_path: &Path,
main_repo: &Path,
instance: &Instance,
_instance: &Instance,
force: bool,
allow_container_removal: bool,
) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
let has_dot_git = worktree_path.join(".git").exists();
@@ -517,9 +450,7 @@ pub fn remove_managed_worktree(
tracing::debug!(target: "git.worktree",
path = %worktree_path.display(),
has_dot_git,
is_sandboxed = instance.is_sandboxed(),
force,
allow_container_removal,
"worktree cleanup starting"
);
@@ -552,25 +483,13 @@ pub fn remove_managed_worktree(
// the aoe lock does not strand the entry.
git_wt.unlock_worktree(worktree_path);
let effective_force = force || instance.is_sandboxed();
match remove_worktree_dir(worktree_path, main_repo, effective_force) {
match remove_worktree_dir(worktree_path, main_repo, force) {
Ok(()) => {
worktree_removed = true;
}
Err(e) => {
tracing::debug!(target: "git.worktree", error = %e, kind = ?e.kind(), "remove_worktree_dir failed (no .git)");
if is_permission_error(&e.to_string())
&& try_sandbox_dir_cleanup(
worktree_path,
main_repo,
instance,
allow_container_removal,
)
{
worktree_removed = true;
} else {
errors.push(format!("Worktree: {}", e));
}
errors.push(format!("Worktree: {}", e));
}
}
// `prune` is repo-wide but lock-respecting: it reaps entries whose
@@ -617,21 +536,8 @@ pub fn remove_managed_worktree(
path = %worktree_path.display(),
"git has no worktree entry for this path; removing the leftover directory by hand"
);
let effective_force = force || instance.is_sandboxed();
match remove_worktree_dir(worktree_path, main_repo, effective_force) {
match remove_worktree_dir(worktree_path, main_repo, force) {
Ok(()) => worktree_removed = true,
Err(e2) if is_permission_error(&e2.to_string()) => {
if try_sandbox_dir_cleanup(
worktree_path,
main_repo,
instance,
allow_container_removal,
) {
worktree_removed = true;
} else {
errors.push(format!("Worktree: {}", e2));
}
}
Err(e2) => errors.push(format!("Worktree: {}", e2)),
}
if worktree_removed {
@@ -639,22 +545,6 @@ pub fn remove_managed_worktree(
errors.push(format!("Worktree: {}", e2));
}
}
}
// Container cleanup deletes everything including .git, so
// git worktree remove won't work afterward. Fall back to
// removing the directory and pruning stale references.
else if is_permission_error(&err_str)
&& try_sandbox_dir_cleanup(
worktree_path,
main_repo,
instance,
allow_container_removal,
)
{
worktree_removed = true;
if let Err(e2) = git_wt.prune_worktrees() {
errors.push(format!("Worktree: {}", e2));
}
} else if is_submodule_blocker(&err_str) {
// Pre-deinit didn't fully resolve it (e.g. a broken
// submodule), or the worktree carries orphaned modules
@@ -698,53 +588,6 @@ pub fn remove_managed_worktree(
}
}
/// Try to clean up a worktree directory using the sandbox container.
///
/// When worktree files are root-owned (from container execution), the host
/// can't delete them directly. This function:
/// 1. Runs `find . -mindepth 1 -delete` inside the container
/// 2. Force-removes the container to release the bind mount
/// 3. Retries directory removal (with VirtioFS delay handling)
///
/// Step 2 is gated on `allow_container_removal`: when the caller
/// opted out of container deletion, we refuse to nuke the container
/// just to free a permission-bound worktree. In that case the caller
/// sees the original Worktree permission error and can decide what
/// to do.
fn try_sandbox_dir_cleanup(
worktree_path: &Path,
main_repo: &Path,
instance: &Instance,
allow_container_removal: bool,
) -> bool {
if !instance.is_sandboxed() {
return false;
}
if !allow_container_removal {
tracing::debug!(target: "git.worktree", "sandbox fallback skipped: caller forbade container removal");
return false;
}
let cleaned = cleanup_sandbox_worktree(instance);
tracing::debug!(target: "git.worktree", cleaned, "container cleanup attempted");
if !cleaned {
return false;
}
let container = DockerContainer::from_session_id(&instance.id);
let rm_result = container.remove(true);
tracing::debug!(target: "git.worktree", ?rm_result, "container force-removed");
container.remove_named_ignore_volumes(&instance.id);
match remove_worktree_dir(worktree_path, main_repo, true) {
Ok(()) => true,
Err(e) => {
tracing::debug!(target: "git.worktree", error = %e, kind = ?e.kind(), "remove_worktree_dir failed after cleanup");
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -774,196 +617,6 @@ mod tests {
assert!(!wt.exists());
}
/// `try_sandbox_dir_cleanup` must respect `allow_container_removal=false`
/// by returning early without touching the container, even when the
/// instance is sandboxed and the worktree would otherwise be a
/// fallback candidate. We can't easily test the docker-side branch
/// without a real container runtime, but we CAN guarantee the
/// early-return: a non-sandboxed instance should also return false,
/// and a sandboxed instance with `allow_container_removal=false`
/// should not even attempt to invoke `cleanup_sandbox_worktree`.
/// This regression test is checked via a side effect: we point the
/// instance at a non-existent worktree path, so the only way the
/// function could reach the post-cleanup `remove_worktree_dir` call
/// is if it bypassed the early-return. If the flag is honored,
/// the function returns false immediately.
#[test]
fn test_try_sandbox_dir_cleanup_respects_allow_container_removal_false() {
use crate::session::{Instance, SandboxInfo};
let mut instance = Instance::new("Test", "/tmp/aoe-cleanup-test-nonexistent");
instance.sandbox_info = Some(SandboxInfo {
enabled: true,
container_id: None,
image: "alpine".to_string(),
container_name: "aoe-sandbox-doesnotexist".to_string(),
extra_env: None,
custom_instruction: None,
before_start_env: Vec::new(),
container_workdir: None,
});
let worktree = std::path::PathBuf::from("/tmp/aoe-cleanup-test-nonexistent");
let main_repo = std::path::PathBuf::from("/tmp/aoe-cleanup-test-main-nonexistent");
// With allow_container_removal=false, must return false without
// touching anything.
let result = try_sandbox_dir_cleanup(&worktree, &main_repo, &instance, false);
assert!(
!result,
"sandbox fallback must bail when allow_container_removal=false"
);
}
/// Anonymous-volume mount-point cruft: when a sandboxed session's
/// in-container preclean (`find . -delete`) runs, the bind mount on
/// the host loses its real contents (including `.git`) but Docker
/// leaves the anonymous-volume mount-point directories behind as
/// empty `target/`, `node_modules/`, `.venv/` dirs. Strict
/// `remove_dir` then fails with ENOTEMPTY ("Directory not empty
/// (os error 66)" on macOS) even though the user opted into
/// destroying the worktree. `remove_managed_worktree` must escalate
/// to `remove_dir_all` for sandboxed instances in this case.
#[test]
fn test_remove_managed_worktree_sandboxed_clears_mount_point_cruft() {
use crate::session::{Instance, SandboxInfo};
let tmp = tempfile::TempDir::new().unwrap();
let main_repo = tmp.path().join("main");
let worktree_path = tmp.path().join("worktree");
std::fs::create_dir(&main_repo).unwrap();
let repo = git2::Repository::init(&main_repo).unwrap();
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
.unwrap();
let status = std::process::Command::new("git")
.args([
"worktree",
"add",
"-b",
"feature/cruft",
worktree_path.to_str().unwrap(),
])
.current_dir(&main_repo)
.output()
.unwrap();
assert!(
status.status.success(),
"git worktree add failed: {}",
String::from_utf8_lossy(&status.stderr)
);
// Simulate post-preclean state: `.git` was wiped along with
// everything else, only the anonymous-volume mount-point dirs
// remain on the host as empty directories.
std::fs::remove_file(worktree_path.join(".git")).unwrap();
std::fs::create_dir(worktree_path.join("target")).unwrap();
std::fs::create_dir(worktree_path.join("node_modules")).unwrap();
std::fs::create_dir(worktree_path.join(".venv")).unwrap();
let mut instance = Instance::new("Test", worktree_path.to_str().unwrap());
instance.sandbox_info = Some(SandboxInfo {
enabled: true,
container_id: None,
image: "alpine".to_string(),
container_name: "aoe-cruft-doesnotexist".to_string(),
extra_env: None,
custom_instruction: None,
before_start_env: Vec::new(),
container_workdir: None,
});
let git_wt = GitWorktree::new(main_repo.clone()).unwrap();
let result = remove_managed_worktree(
&git_wt,
&worktree_path,
&main_repo,
&instance,
false, // force = false; sandboxed escalation must kick in
true, // allow_container_removal (not exercised here)
);
assert!(
result.is_ok(),
"sandboxed removal must clear mount-point cruft: {:?}",
result
);
assert!(
!worktree_path.exists(),
"worktree dir should be gone after sandboxed cleanup"
);
}
/// Counterpart: non-sandboxed sessions with a missing `.git` get
/// the strict behavior. A leftover non-empty dir there usually
/// means the user did something manual (moved files in, partial
/// recovery), and silently nuking it would be a regression.
#[test]
fn test_remove_managed_worktree_non_sandboxed_preserves_strict_dir_check() {
use crate::session::Instance;
let tmp = tempfile::TempDir::new().unwrap();
let main_repo = tmp.path().join("main");
let worktree_path = tmp.path().join("worktree");
std::fs::create_dir(&main_repo).unwrap();
let repo = git2::Repository::init(&main_repo).unwrap();
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
.unwrap();
let status = std::process::Command::new("git")
.args([
"worktree",
"add",
"-b",
"feature/no-sandbox-cruft",
worktree_path.to_str().unwrap(),
])
.current_dir(&main_repo)
.output()
.unwrap();
assert!(status.status.success());
std::fs::remove_file(worktree_path.join(".git")).unwrap();
std::fs::create_dir(worktree_path.join("target")).unwrap();
// No sandbox_info: instance.is_sandboxed() is false.
let instance = Instance::new("Test", worktree_path.to_str().unwrap());
let git_wt = GitWorktree::new(main_repo.clone()).unwrap();
let result =
remove_managed_worktree(&git_wt, &worktree_path, &main_repo, &instance, false, false);
assert!(
result.is_err(),
"non-sandboxed removal must NOT silently force-clear leftover dirs"
);
assert!(
worktree_path.exists(),
"worktree dir should still exist after strict failure"
);
}
#[test]
fn test_try_sandbox_dir_cleanup_returns_false_for_non_sandboxed() {
use crate::session::Instance;
let instance = Instance::new("Test", "/tmp/aoe-cleanup-test-nonexistent");
// No sandbox_info set.
let worktree = std::path::PathBuf::from("/tmp/aoe-cleanup-test-nonexistent");
let main_repo = std::path::PathBuf::from("/tmp/aoe-cleanup-test-main-nonexistent");
// Even with allow_container_removal=true, a non-sandboxed
// instance must early-return.
let result = try_sandbox_dir_cleanup(&worktree, &main_repo, &instance, true);
assert!(!result);
}
#[test]
fn test_is_permission_error_matches() {
assert!(is_permission_error("Permission denied (os error 13)"));
@@ -1087,83 +740,6 @@ mod tests {
));
}
/// A trashed worktree whose git admin entry went missing (pruned, or the
/// main repo re-cloned) keeps its checkout and a now-dangling `.git`
/// pointer on disk. `git worktree remove` refuses with "is not a working
/// tree", which is unfixable by retrying: before #3171 that error fell
/// through to the generic arm, so `remove_managed_worktree` failed and
/// the hourly trash auto-purge re-failed on the same session forever
/// (observed retrying every hour with no progress). The recovery is to
/// delete the leftover directory by hand and prune, the same as a
/// missing `.git`.
#[test]
fn test_remove_managed_worktree_recovers_from_missing_admin_entry() {
use crate::session::Instance;
let tmp = tempfile::TempDir::new().unwrap();
let main_repo = tmp.path().join("main");
let worktree_path = tmp.path().join("trashed");
std::fs::create_dir(&main_repo).unwrap();
let repo = git2::Repository::init(&main_repo).unwrap();
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
.unwrap();
let status = std::process::Command::new("git")
.args([
"worktree",
"add",
"-b",
"feature/orphaned-admin-entry",
worktree_path.to_str().unwrap(),
])
.current_dir(&main_repo)
.output()
.unwrap();
assert!(
status.status.success(),
"git worktree add failed: {}",
String::from_utf8_lossy(&status.stderr)
);
// Orphan the checkout: drop git's admin entry while leaving the
// worktree (and its `.git` pointer file) on disk. This is the exact
// state that makes both `worktree unlock` and `worktree remove`
// report "is not a working tree".
let admin = main_repo.join(".git/worktrees");
std::fs::remove_dir_all(&admin).unwrap();
assert!(
worktree_path.join(".git").exists(),
"test must exercise the has_dot_git branch"
);
let instance = Instance::new("Test", worktree_path.to_str().unwrap());
let git_wt = GitWorktree::new(main_repo.clone()).unwrap();
// force=true mirrors the auto-purge, which forces removal so a dirty
// tree can't pin an expired session in the trash forever.
let result =
remove_managed_worktree(&git_wt, &worktree_path, &main_repo, &instance, true, false);
assert!(
result.is_ok(),
"orphaned admin entry must not fail removal: {:?}",
result
);
assert!(
!worktree_path.exists(),
"leftover worktree dir should be gone"
);
// Idempotent: the purge may re-run before its registry entry drains.
let again =
remove_managed_worktree(&git_wt, &worktree_path, &main_repo, &instance, true, false);
assert!(again.is_ok(), "second removal must be a no-op: {:?}", again);
}
fn init_repo_with_commit() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::TempDir::new().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
-21
View File
@@ -526,27 +526,6 @@ pub(crate) fn unlink_session_id_via_guard(instance_id: &str) -> Result<()> {
}
}
/// Ensure the per-instance hook directory exists with `dir_guard` discipline
/// and return its host path. Used by callers that hand the path to an
/// external resolver (Docker bind-mount source, sidecar config writer)
/// rather than performing in-process I/O directly.
///
/// The function calls `open_instance_dir` to verify-and-create with
/// `*at`+`O_NOFOLLOW`+`fstat-on-fd`, then drops the fd and returns the
/// resolved path. Closes both attack vectors that an unguarded
/// `create_dir_all` would re-introduce: self-DoS at default umask 022
/// (would create `0o755`, which `with_hook_base`'s `verify_dir_metadata`
/// would reject) and the multi-tenant pre-squat + symlink-swap race
/// against Docker's bind-mount resolution.
///
/// Caller policy on `Err`: skip the bind-mount push, surface a
/// `tracing::warn!` and let the agent boot without status hooks
/// (pane-detection fallback).
pub(crate) fn ensure_instance_dir_path(instance_id: &str) -> Result<PathBuf> {
let _fd = open_instance_dir(instance_id)?;
Ok(hook_base_path().join(instance_id))
}
// Cleanup.
/// Remove the per-instance subdir and every file inside, never following
+1 -65
View File
@@ -23,8 +23,7 @@ use serde_json::Value;
#[cfg(test)]
pub(crate) use dir_guard::{clear_base_override_for_test, override_base_for_test, reset_for_test};
pub(crate) use dir_guard::{
ensure_instance_dir_path, hook_base_path, unlink_session_id_via_guard,
write_session_id_via_guard,
hook_base_path, unlink_session_id_via_guard, write_session_id_via_guard,
};
pub use status_file::{
cleanup_hook_status_dir, hook_status_dir, read_hook_session_id, read_hook_status,
@@ -683,42 +682,6 @@ pub fn install_hooks(
})
}
/// Read `[hooks.state]` from `config_path` under the codex config lock and
/// return it for later restoration. Inverse of [`restore_codex_hooks_state`];
/// returns `Ok(None)` when the file is absent so callers can no-op the
/// snapshot/restore bracket.
pub(crate) fn snapshot_codex_hooks_state(config_path: &Path) -> Result<Option<toml_edit::Item>> {
if !config_path.exists() {
return Ok(None);
}
with_codex_config_lock(config_path, || {
let config = read_codex_config(config_path)?;
Ok(config
.get("hooks")
.and_then(|hooks| hooks.as_table_like())
.and_then(|hooks| hooks.get("state"))
.cloned())
})
}
/// Write `state` back into the `[hooks.state]` table of `config_path`,
/// taking the codex config lock. Inverse of [`snapshot_codex_hooks_state`];
/// together they bracket destructive rewrites of `config.toml` (e.g. the
/// host-to-sandbox refresh in [`crate::session::container_config`]) so
/// Codex's user-trust block survives. Unconditionally overwrites any
/// `state` already present at `config_path`; pair only with a fresh
/// snapshot from the authoritative source.
pub(crate) fn restore_codex_hooks_state(config_path: &Path, state: toml_edit::Item) -> Result<()> {
with_codex_config_lock(config_path, || {
let mut config = read_codex_config(config_path)?;
let hooks = ensure_codex_hooks_table(&mut config)?;
hooks.insert("state", state);
write_codex_config(config_path, &config)?;
Ok(())
})
}
fn with_codex_config_lock<T>(config_path: &Path, f: impl FnOnce() -> Result<T>) -> Result<T> {
let lock_base_path = crate::session::resolve_symlink_chain(config_path)?;
with_config_lock(&lock_base_path, "toml.lock", f)
@@ -778,33 +741,6 @@ fn read_codex_config(config_path: &Path) -> Result<toml_edit::DocumentMut> {
}
}
fn ensure_codex_hooks_table(config: &mut toml_edit::DocumentMut) -> Result<&mut toml_edit::Table> {
let root = config.as_table_mut();
if !root.contains_key("hooks") {
root.insert("hooks", toml_edit::Item::Table(toml_edit::Table::new()));
}
let hooks_item = root
.get_mut("hooks")
.ok_or_else(|| anyhow::anyhow!("hooks key was not created"))?;
if !hooks_item.is_table() {
let old_item = std::mem::take(hooks_item);
match old_item.into_table() {
Ok(table) => {
*hooks_item = toml_edit::Item::Table(table);
}
Err(old_item) => {
*hooks_item = old_item;
return Err(anyhow::anyhow!("Codex hooks key is not a TOML table"));
}
}
}
hooks_item
.as_table_mut()
.ok_or_else(|| anyhow::anyhow!("Codex hooks key is not a TOML table"))
}
/// Mark `project_path` as trusted in Codex's `config.toml` by merging
/// `[projects."<project_path>"].trust_level = "trusted"`.
///
-1
View File
@@ -9,7 +9,6 @@ pub mod agents;
pub mod build_info;
pub mod claude_settings;
pub mod cli;
pub mod containers;
/// Protocol-agnostic durable event log, the storage substrate behind the
/// ACP transcript store. Serve-gated because its only consumer today is the
/// serve-gated acp module.
+1 -16
View File
@@ -482,18 +482,8 @@ impl std::fmt::Display for LogFilterError {
impl std::error::Error for LogFilterError {}
/// Optional top-of-stack layer injected at subscriber init. Only the
/// serve daemon supplies a real one (the per-session tee, see
/// `crate::acp::session_tee`); the acp module is `serve`-gated, so without
/// that feature the slot is the no-op `Identity` layer and callers always
/// pass `None`.
#[cfg(feature = "serve")]
pub type TeeLayer = crate::acp::session_tee::SessionTeeLayer;
#[cfg(not(feature = "serve"))]
pub type TeeLayer = tracing_subscriber::layer::Identity;
pub fn init_subscriber(target: SubscriberTarget, filter: String) -> InitResult {
init_subscriber_with_options(target, filter, false, None)
init_subscriber_with_options(target, filter, false)
}
/// Event formatter that mirrors the default Full output (RFC3339-ish
@@ -541,7 +531,6 @@ pub fn init_subscriber_with_options(
target: SubscriberTarget,
filter: String,
show_spans: bool,
session_tee: Option<TeeLayer>,
) -> InitResult {
let parsed = match EnvFilter::builder().with_regex(false).parse(&filter) {
Ok(f) => f,
@@ -579,7 +568,6 @@ pub fn init_subscriber_with_options(
Registry::default()
.with(reload_layer)
.with(fmt_layer)
.with(session_tee)
.try_init()
.map_err(|e| e.to_string())
} else {
@@ -590,7 +578,6 @@ pub fn init_subscriber_with_options(
Registry::default()
.with(reload_layer)
.with(fmt_layer)
.with(session_tee)
.try_init()
.map_err(|e| e.to_string())
}
@@ -606,7 +593,6 @@ pub fn init_subscriber_with_options(
Registry::default()
.with(reload_layer)
.with(fmt_layer)
.with(session_tee)
.try_init()
.map_err(|e| e.to_string())
} else {
@@ -616,7 +602,6 @@ pub fn init_subscriber_with_options(
Registry::default()
.with(reload_layer)
.with(fmt_layer)
.with(session_tee)
.try_init()
.map_err(|e| e.to_string())
}
-16
View File
@@ -162,26 +162,10 @@ async fn main() -> Result<()> {
SubscriberTarget::File(p, _) => Some(p.clone()),
SubscriberTarget::Stdout => None,
};
// Only the serve daemon multiplexes many sessions, so it is
// the one process that tees session-scoped tracing into each
// session's acp-workers/<id>.log (#1864). The acp module is
// serve-gated, so the tee only exists in serve builds.
#[cfg(feature = "serve")]
let session_tee = if matches!(
ctx,
ProcessContext::ServeForeground | ProcessContext::ServeDaemonChild
) {
Some(agent_of_empires::acp::session_tee::SessionTeeLayer::new())
} else {
None
};
#[cfg(not(feature = "serve"))]
let session_tee: Option<logging::TeeLayer> = None;
let res = logging::init_subscriber_with_options(
resolution.target,
filter,
log_cfg.show_spans,
session_tee,
);
if let Some(w) = resolution.warning {
// Emit through the subscriber that just came up.
+5 -75
View File
@@ -36,10 +36,9 @@ pub struct SystemSample {
pub swap_used_bytes: u64,
}
/// One agent's resource usage. Every figure is optional because a sandboxed
/// agent's numbers come from the container runtime, which may not have a
/// sample yet (or at all, on a runtime without a stats command); reporting
/// unknown is the honest reading, a zero would not be.
/// One agent's resource usage. Every figure is optional because a process
/// tree can vanish between the scan and the read; reporting unknown is the
/// honest reading, a zero would not be.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct AgentMetric {
pub id: String,
@@ -47,9 +46,6 @@ pub struct AgentMetric {
pub cpu_fraction: Option<f64>,
pub rss_bytes: Option<u64>,
pub procs: Option<usize>,
/// Measured inside a sandbox container rather than on the host process
/// tree. The memory figure is then the container's usage, not an RSS sum.
pub sandboxed: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
@@ -148,33 +144,6 @@ fn eligible_instance(inst: &Instance) -> bool {
/// One row's figures: `(cpu_fraction, memory_bytes, procs)`.
type RowFigures = (Option<f64>, Option<u64>, Option<usize>);
/// The sandbox container backing `inst`, if it has one. Both agent populations
/// route through this, so a tmux pane and a structured worker cannot disagree
/// about whether a session is sandboxed.
fn sandbox_container_name(inst: &Instance) -> Option<&str> {
inst.sandbox_info
.as_ref()
.filter(|s| s.enabled)
.map(|s| s.container_name.as_str())
}
/// Figures as the container runtime reports them, or all-unknown when it has
/// no sample for this container: a cold cache, a stopped container, or a
/// runtime with no stats command. Unknown renders "?"; a zero would read as a
/// measured idle.
fn container_figures(stats: &crate::containers::stats::StatsMap, name: &str) -> RowFigures {
match stats.get(name) {
Some(stats) => (
// `cpu_percent` is per-core (100 == one core saturated); the table
// reads as a share of the whole host, like the host rows.
Some(stats.cpu_percent / 100.0 / logical_cpus() as f64),
Some(stats.mem_used_bytes),
Some(stats.pids),
),
None => (None, None, None),
}
}
/// Figures summed over a host process tree.
fn host_figures(
pids: &[u32],
@@ -231,27 +200,6 @@ fn aggregate_agents(
.filter(crate::process::worker_registry::is_record_live)
.collect();
// Structured sessions are excluded from `eligible`, so their sandboxes
// have to be counted here or a host whose only sandbox runs under a
// structured worker would never fetch the map its row needs.
#[cfg(feature = "serve")]
let sandboxed_worker = worker_records.iter().any(|rec| {
instances
.iter()
.any(|i| i.id == rec.session_id && sandbox_container_name(i).is_some())
});
#[cfg(not(feature = "serve"))]
let sandboxed_worker = false;
// Skip the runtime's stats pass unless a sandbox session is loaded;
// `cached_stats` then hands back the last completed map without blocking.
// Not gated on the health surface being open: the sampler also runs for
// the compact strip and for the undiscovered-tip check, so a host with a
// sandbox pays one refresh per TTL whenever the TUI is sampling at all.
let container_stats = (eligible.iter().any(|i| sandbox_container_name(i).is_some())
|| sandboxed_worker)
.then(crate::containers::stats::cached_stats)
.unwrap_or_default();
let alive = crate::session::recovery::orphaned_agents_alive(&eligible);
for (inst, is_alive) in eligible.iter().zip(alive) {
if !is_alive {
@@ -275,23 +223,13 @@ fn aggregate_agents(
stack.extend(children);
}
}
// The pid tree is still walked for a sandboxed session, so its pane
// processes are claimed and cannot be double-counted onto a neighbour,
// but its figures come from the container instead: the pane holds a
// `docker exec` client, while the agent runs in the container, off
// this process tree entirely.
let sandbox_container = sandbox_container_name(inst);
let (cpu_fraction, rss_bytes, procs) = match sandbox_container {
Some(name) => container_figures(&container_stats, name),
None => host_figures(&pids, &by_pid, elapsed, previous),
};
let (cpu_fraction, rss_bytes, procs) = host_figures(&pids, &by_pid, elapsed, previous);
rows.push(AgentMetric {
id: inst.id.clone(),
title: inst.title.clone(),
cpu_fraction,
rss_bytes,
procs,
sandboxed: sandbox_container.is_some(),
});
}
@@ -314,15 +252,8 @@ fn aggregate_agents(
stack.extend(children);
}
}
// A sandboxed structured session wraps its agent in `docker exec` just
// as a tmux pane does (see `spawn_runner_detached`), so the host tree
// here is the runner shim plus that client, not the agent.
let inst = instances.iter().find(|i| i.id == rec.session_id);
let sandbox_container = inst.and_then(sandbox_container_name);
let (cpu_fraction, rss_bytes, procs) = match sandbox_container {
Some(name) => container_figures(&container_stats, name),
None => host_figures(&pids, &by_pid, elapsed, previous),
};
let (cpu_fraction, rss_bytes, procs) = host_figures(&pids, &by_pid, elapsed, previous);
let title = inst
.map(|i| i.title.clone())
.unwrap_or_else(|| rec.agent_name.clone());
@@ -332,7 +263,6 @@ fn aggregate_agents(
cpu_fraction,
rss_bytes,
procs,
sandboxed: sandbox_container.is_some(),
});
}
+2 -9
View File
@@ -1932,15 +1932,8 @@ fn init_runner_logging(session_id: &str) -> Result<()> {
let resolution =
crate::logging::resolve_sink(&log_cfg, &app_dir, crate::logging::ProcessContext::Runner);
// The runner is single-session; its tracing still flows to the shared
// debug.log. The per-session tee runs only in the daemon (#1864), so
// no tee layer is installed here.
let init = crate::logging::init_subscriber_with_options(
resolution.target,
filter,
log_cfg.show_spans,
None,
);
let init =
crate::logging::init_subscriber_with_options(resolution.target, filter, log_cfg.show_spans);
if let Some(c) = init.controller {
crate::logging::install_controller(c);
}
+1 -32
View File
@@ -1355,18 +1355,9 @@ async fn resume_one(state: Arc<AppState>, target: ResumeTarget) -> ResumeOutcome
let supervisor = Arc::clone(&state.acp_supervisor);
let cwd = PathBuf::from(&project_path);
// Reconstruct sandbox context from the live instance state
// so the reattached session's fs/terminal handlers can
// still route across the container boundary.
let sandbox_for_attach = {
let instances = state.instances.read().await;
instances
.iter()
.find(|i| i.id == id)
.and_then(|i| i.sandbox_info.clone())
};
let attach_res = timeout(
Duration::from_secs(3),
supervisor.attach(id.clone(), cwd, vec![], in_flight_turn, sandbox_for_attach),
supervisor.attach(id.clone(), cwd, vec![], in_flight_turn),
)
.await;
match attach_res {
@@ -1677,27 +1668,6 @@ async fn build_spawn_request(
&cwd,
)
.await;
let sandbox_info = match crate::acp::sandbox::ensure_container_for_session(
&service.instances,
&inst_lock,
&target.id,
false,
)
.await
{
Ok(info) => info,
Err(e) => {
let message = format!("sandbox container ensure failed: {e}");
tracing::warn!(
target: "acp.supervisor",
session = %target.id,
"reconciler container ensure failed: {message}"
);
supervisor.publish_startup_error(&target.id, message);
return Err(());
}
};
// Thread the session profile through regardless of sandboxing: the
// spawn path resolves agent_acp_cmd and worker env from it, so a
// non-sandbox session on a non-default profile must not fall back to
@@ -1713,7 +1683,6 @@ async fn build_spawn_request(
effort: acp_effort,
stored_acp_session_id: target.stored_acp_session_id.clone(),
fork_from,
sandbox_info,
source_profile: Some(target.source_profile.clone()),
yolo_mode: target.yolo_mode,
acp_mode_id,
+20 -164
View File
@@ -409,25 +409,6 @@ pub async fn spawn_acp(
// parent id instead of session/new. Cleared once the forked id lands.
let fork_from = instance.fork_pending.clone();
let sandbox_info = match crate::acp::sandbox::ensure_container_for_session_locked(
&state.instances,
&id,
false,
)
.await
{
Ok(info) => info,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("sandbox container ensure failed: {e}"),
)
.into_response();
}
};
// Pass the session profile through regardless of sandboxing so the
// spawn path resolves agent_acp_cmd and worker env from the right
// profile for non-sandbox sessions too.
let source_profile = Some(instance.source_profile.clone());
let agent_for_response = agent.clone();
@@ -465,7 +446,6 @@ pub async fn spawn_acp(
effort,
stored_acp_session_id,
fork_from,
sandbox_info,
source_profile,
yolo_mode,
acp_mode_id,
@@ -611,72 +591,6 @@ async fn install_on_host(package: &str) -> Result<std::process::Output, axum::re
}
}
/// Run `npm install -g <package>` inside the session's sandbox container via
/// `<runtime> exec`. The container must be running for `exec` to land, so a
/// stopped/absent container returns a targeted error rather than a cryptic
/// runtime one. Mirrors [`install_on_host`]'s fixed-argv, no-shell, bounded
/// execution.
async fn install_in_container(
session_id: &str,
package: &str,
) -> Result<std::process::Output, axum::response::Response> {
use crate::containers::DockerContainer;
let sid = session_id.to_string();
let running = tokio::task::spawn_blocking(move || {
DockerContainer::from_session_id(&sid)
.is_running()
.unwrap_or(false)
})
.await
.unwrap_or(false);
if !running {
return Err((
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "container_not_running",
"message": "The session's sandbox container is not running. Open the session (which starts its container) and try again.",
})),
)
.into_response());
}
let container_name = DockerContainer::generate_name(session_id);
let runtime_bin = crate::containers::runtime_binary();
match tokio::time::timeout(
INSTALL_TIMEOUT,
tokio::process::Command::new(runtime_bin)
.arg("exec")
.arg(&container_name)
.arg("npm")
.arg("install")
.arg("-g")
.arg(package)
.kill_on_drop(true)
.output(),
)
.await
{
Ok(Ok(o)) => Ok(o),
Ok(Err(e)) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "exec_start_failed",
"message": format!("`{runtime_bin} exec` failed to start: {e}"),
})),
)
.into_response()),
Err(_) => Err((
StatusCode::GATEWAY_TIMEOUT,
Json(serde_json::json!({
"error": "install_timeout",
"message": "`npm install -g` in the sandbox did not finish within 180s.",
})),
)
.into_response()),
}
}
/// `POST /api/sessions/{id}/acp/install-agent`: run `npm install -g <pkg>`
/// for the session's agent where the adapter actually lives, then let the
/// client respawn.
@@ -769,29 +683,18 @@ pub async fn install_agent(
let inst_lock = state.instance_lock(&id).await;
let _guard = inst_lock.lock().await;
// Run the install where the adapter actually lives: on the host for a
// host-run session, inside the container for a sandboxed one.
let output = if instance.is_sandboxed() {
match install_in_container(&id, package).await {
Ok(output) => output,
Err(resp) => return resp,
}
} else {
match install_on_host(package).await {
Ok(output) => output,
Err(resp) => return resp,
}
let output = match install_on_host(package).await {
Ok(output) => output,
Err(resp) => return resp,
};
// On success a *host* install updates the global npm prefix shared by
// every host-run session, so others parked on the same binary's
// compatibility check can recover too; queue them for the reconciler to
// fresh-spawn (the current session is excluded because the client
// respawns it directly and a double-spawn would race). A *container*
// install only touched this session's container, so no cross-session
// recovery applies. See #2109.
// On success the install updates the global npm prefix shared by every
// session, so others parked on the same binary's compatibility check can
// recover too; queue them for the reconciler to fresh-spawn (the current
// session is excluded because the client respawns it directly and a
// double-spawn would race). See #2109.
let mut recovered_sessions = 0;
if output.status.success() && !instance.is_sandboxed() {
if output.status.success() {
for other in state
.acp_supervisor
.incompatible_sessions_for_binary(&binary)
@@ -1005,27 +908,6 @@ pub async fn switch_acp_agent(
}
let cwd = PathBuf::from(&instance.project_path);
let inst_lock = state.instance_lock(&id).await;
let sandbox_info = match crate::acp::sandbox::ensure_container_for_session(
&state.instances,
&inst_lock,
&id,
false,
)
.await
{
Ok(info) => info,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("sandbox container ensure failed: {e}"),
)
.into_response();
}
};
// Pass the session profile through regardless of sandboxing so the
// spawn path resolves agent_acp_cmd and worker env from the right
// profile for non-sandbox sessions too.
let source_profile = Some(instance.source_profile.clone());
let model = req.model.clone().or(instance.agent_model.clone());
@@ -1049,7 +931,6 @@ pub async fn switch_acp_agent(
stored_acp_session_id: None,
// Switching ACP backend starts a fresh session, never a fork.
fork_from: None,
sandbox_info,
source_profile,
yolo_mode: instance.yolo_mode,
acp_mode_id: instance.acp_mode_id.clone(),
@@ -1900,20 +1781,17 @@ pub async fn acp_enable(
// in `agent_session_id`, not `acp_session_id`. When present on the host (the
// seeded `session/load` hard-fails on a missing id), carry it into the
// structured spawn so the conversation continues in structured view. The
// in-container transcript can't be probed from the host, so sandboxed
// sessions attempt the load unconditionally.
let transcript_present = instance.is_sandboxed()
|| instance
.agent_session_id
.as_deref()
.map(|sid| {
!crate::session::capture::claude_host_transcript_confirmed_absent(
&instance.project_path,
sid,
&instance.resolved_host_environment(),
)
})
.unwrap_or(false);
let transcript_present = instance
.agent_session_id
.as_deref()
.map(|sid| {
!crate::session::capture::claude_host_transcript_confirmed_absent(
&instance.project_path,
sid,
&instance.resolved_host_environment(),
)
})
.unwrap_or(false);
let seed = resolve_structured_seed(
&instance.tool,
&agent_name,
@@ -1935,28 +1813,7 @@ pub async fn acp_enable(
&instance.command,
);
let tool_for_spawn = instance.tool.clone();
let state_for_spawn = state.clone();
tokio::spawn(async move {
let inst_lock = state_for_spawn.instance_lock(&session_id).await;
let sandbox_info = match crate::acp::sandbox::ensure_container_for_session(
&state_for_spawn.instances,
&inst_lock,
&session_id,
false,
)
.await
{
Ok(info) => info,
Err(e) => {
let message = format!("container start failed: {e}");
tracing::warn!(target: "acp.switch", session = %session_id, "container ensure failed: {e}");
supervisor.publish_startup_error(&session_id, message);
return;
}
};
// Pass the session profile through regardless of sandboxing so the
// spawn path resolves agent_acp_cmd and worker env from the right
// profile for non-sandbox sessions too.
let source_profile = Some(profile_for_spawn);
if let Err(e) = supervisor
.spawn(crate::acp::supervisor::SpawnRequest {
@@ -1970,7 +1827,6 @@ pub async fn acp_enable(
effort,
stored_acp_session_id,
fork_from,
sandbox_info,
source_profile,
yolo_mode,
acp_mode_id,
+10 -14
View File
@@ -49,12 +49,12 @@ pub use git::{clone_repo, is_git_repo, list_branches};
pub use log_level::{get_log_level, patch_log_level};
pub use projects::{create_project, delete_project, list_projects, update_project};
pub use sessions::{
create_session, delete_session, delete_workspace, ensure_container_terminal, ensure_session,
ensure_terminal, force_smart_rename, get_recent_projects, kill_terminal, list_sessions,
paste_image, preview_volume_ignores_globs, read_output, rename_session, restore_session,
search_sessions, send_message, serve_session_artifact, session_file, set_worktree_name,
start_session, stop_session, trash_session, update_session_archive, update_session_color,
update_session_group, update_session_notifications, update_session_pin, update_session_unread,
create_session, delete_session, delete_workspace, ensure_session, ensure_terminal,
force_smart_rename, get_recent_projects, kill_terminal, list_sessions, paste_image,
read_output, rename_session, restore_session, search_sessions, send_message,
serve_session_artifact, session_file, set_worktree_name, start_session, stop_session,
trash_session, update_session_archive, update_session_color, update_session_group,
update_session_notifications, update_session_pin, update_session_unread,
update_workspace_ordering, CleanupDefaults, OutputQuery, SendMessageRequest, SessionResponse,
};
// Shared by the status poll loop's auto-unread persistence; not a route handler.
@@ -67,11 +67,10 @@ pub(crate) use sessions::purge_expired_trash;
pub(crate) use sessions::reconcile_trashed_worktrees;
pub use system::{
browse_filesystem, create_profile, default_profile, delete_profile, dismiss_update,
docker_status, filesystem_home, get_about, get_current_theme, get_profile_settings,
get_resolved_theme, get_settings, get_settings_resolved, get_settings_schema, get_web_ui_state,
list_agents, list_groups, list_profiles, list_themes, mark_volume_ignores_globs_acknowledged,
patch_web_ui_state, post_dashboard_presence, rename_profile, update_profile_settings,
update_settings, update_theme,
filesystem_home, get_about, get_current_theme, get_profile_settings, get_resolved_theme,
get_settings, get_settings_resolved, get_settings_schema, get_web_ui_state, list_agents,
list_groups, list_profiles, list_themes, patch_web_ui_state, post_dashboard_presence,
rename_profile, update_profile_settings, update_settings, update_theme,
};
/// Canonical 404 for a session id that does not resolve to a live instance.
@@ -262,7 +261,6 @@ mod tests {
"send_message",
"ensure_session",
"ensure_terminal",
"ensure_container_terminal",
"update_session_group",
"update_session_notifications",
"update_session_pin",
@@ -295,7 +293,6 @@ mod tests {
"update_settings",
"dismiss_update",
"patch_web_ui_state",
"mark_volume_ignores_globs_acknowledged",
"create_profile",
"delete_profile",
"rename_profile",
@@ -402,7 +399,6 @@ mod tests {
"send_message",
"ensure_session",
"ensure_terminal",
"ensure_container_terminal",
"update_session_group",
"update_session_notifications",
"update_session_pin",
+17 -442
View File
@@ -56,7 +56,6 @@ pub struct SessionResponse {
/// or those that took the repo's default branch. See #948.
#[serde(skip_serializing_if = "Option::is_none")]
pub base_branch: Option<String>,
pub is_sandboxed: bool,
/// True when the session was created with `--scratch`; the
/// `project_path` points at an auto-provisioned directory under
/// `<app_dir>/scratch/<id>/` that the deletion path removes. The web
@@ -310,7 +309,6 @@ pub struct WorkspaceRepoSummary {
pub struct CleanupDefaults {
pub delete_worktree: bool,
pub delete_branch: bool,
pub delete_sandbox: bool,
/// Resolved `session.delete_to_trash`: when true, the web delete dialog
/// defaults to "Move to Trash" with a permanent-delete disclosure;
/// when false it goes straight to permanent delete. See #2489.
@@ -382,7 +380,6 @@ impl SessionResponse {
.worktree_info
.as_ref()
.and_then(|w| w.base_branch.clone()),
is_sandboxed: inst.is_sandboxed(),
scratch: inst.scratch,
favorited: inst.is_favorited(),
color: inst.color.clone(),
@@ -409,7 +406,6 @@ impl SessionResponse {
cleanup_defaults: CleanupDefaults {
delete_worktree: true,
delete_branch: false,
delete_sandbox: true,
delete_to_trash: true,
},
remote_owner: None,
@@ -744,7 +740,6 @@ pub async fn list_sessions(
CleanupDefaults {
delete_worktree: cfg.worktree.auto_cleanup,
delete_branch: cfg.worktree.should_delete_branch_on_cleanup(),
delete_sandbox: cfg.sandbox.auto_cleanup,
delete_to_trash: cfg.session.delete_to_trash,
}
});
@@ -818,7 +813,6 @@ pub async fn list_sessions(
&inst.title,
&inst.tool,
cfg.rename_agent,
inst.is_sandboxed(),
&inst.command,
cfg.overrides,
)
@@ -1156,47 +1150,7 @@ async fn quiesce_structured_worker_for_worktree_move(
}
}
/// Release a sandboxed session's hold on its worktree mount ahead of a
/// `git worktree move`, on the blocking pool, and report whether the
/// worktree is *still* held.
///
/// NOT a read-only probe: for a container that is merely stopped this
/// removes it, because a surviving container keeps pinning the bind mount
/// and the rename would fail. Only call it on a path that is about to
/// perform the move. See `ensure_sandbox_container_released` for the
/// running-vs-stopped split.
///
/// Fails closed at the async boundary: a `spawn_blocking` panic or
/// cancellation reports the worktree as held (with a `warn!` log), so
/// the caller rejects the mutating request with `409 CONFLICT` rather
/// than risk renaming against a possibly-live container mount. Sharing
/// this helper between `rename_session` and `set_worktree_name` keeps
/// the fail-closed policy synchronized across the two endpoints (#2596).
async fn ensure_sandbox_container_released_blocking(id: &str, is_sandboxed: bool) -> bool {
let probe_id = id.to_string();
let log_id = id.to_string();
tokio::task::spawn_blocking(move || {
crate::session::worktree_edit::ensure_sandbox_container_released(&probe_id, is_sandboxed)
})
.await
.unwrap_or_else(|e| {
tracing::warn!(
target: "server.api.sessions",
session = %log_id,
error = %e,
"sandbox container release task failed at the async boundary; failing closed and reporting the worktree as held rather than renaming against a possibly-live container mount"
);
true
})
}
/// Rename a session's title (and, when tied, its worktree directory).
///
/// The sandbox container probe runs on the blocking pool via
/// `ensure_sandbox_container_released_blocking`, which fails closed on a
/// `spawn_blocking` panic or cancellation so the rename is rejected
/// with `409 CONFLICT` rather than proceeding against a possibly-live
/// container mount.
pub async fn rename_session(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
@@ -1289,7 +1243,6 @@ pub async fn rename_session(
.as_ref()
.map(|worktree| worktree.branch.clone());
let status = fresh.status;
let is_sandboxed = fresh.is_sandboxed();
let is_structured = fresh.is_structured();
// Tied mode (#1927): renaming an aoe-managed worktree session also moves
@@ -1355,11 +1308,7 @@ pub async fn rename_session(
body.rename_branch,
)
});
let container_holds = !status.blocks_worktree_edit()
&& moves_worktree
&& ensure_sandbox_container_released_blocking(&id, is_sandboxed).await;
if (moves_worktree || renames_branch) && (status.blocks_worktree_edit() || container_holds)
{
if (moves_worktree || renames_branch) && status.blocks_worktree_edit() {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({
@@ -1400,21 +1349,6 @@ pub async fn rename_session(
match edit {
Ok(Ok((path, branch))) => {
// The dir moved (path changed): a sandbox container created
// against the old path is now stale, so drop it to force a
// fresh create on next start. A branch-only edit leaves the
// path (and the mount) unchanged, so skip it then. Awaited so
// the response only lands once the stale container is gone; an
// immediate restart must not race the removal and revive it.
if path != current_path {
let id = id.clone();
let _ = tokio::task::spawn_blocking(move || {
crate::session::worktree_edit::discard_sandbox_container_after_move(
&id,
is_sandboxed,
)
})
.await;
}
new_path = Some(path);
new_branch = branch;
}
@@ -1635,7 +1569,7 @@ pub async fn set_worktree_name(
let lock = state.instance_lock(&id).await;
let _guard = lock.lock().await;
let (worktree_info, current_path, status, profile, is_sandboxed, is_structured) = {
let (worktree_info, current_path, status, profile, is_structured) = {
let instances = state.instances.read().await;
let Some(inst) = instances.iter().find(|i| i.id == id) else {
return super::session_not_found();
@@ -1645,7 +1579,6 @@ pub async fn set_worktree_name(
inst.project_path.clone(),
inst.status,
inst.source_profile.clone(),
inst.is_sandboxed(),
inst.is_structured(),
)
};
@@ -1680,16 +1613,13 @@ pub async fn set_worktree_name(
// the user has to stop, same as the active-status case.
// Short-circuited twice, because the helper removes a stopped container:
// once on the status check, so a request about to be rejected never
// discards, and once on whether the directory is actually going to move, so
// a no-op or branch-only edit does not either.
// once on whether the directory is actually going to move, so a no-op or
// branch-only edit does not do needless work.
let moves_worktree = crate::session::worktree_edit::worktree_move_required(
std::path::Path::new(&current_path),
&name,
);
let container_holds = !status.blocks_worktree_edit()
&& moves_worktree
&& ensure_sandbox_container_released_blocking(&id, is_sandboxed).await;
if status.blocks_worktree_edit() || container_holds {
if status.blocks_worktree_edit() {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({
@@ -1749,22 +1679,6 @@ pub async fn set_worktree_name(
}
};
// The dir moved (path changed): a sandbox container created against the old
// path is now stale, so drop it to force a fresh create on next start. A
// branch-only edit leaves the path (and the mount) unchanged. Awaited so
// the response only lands once the stale container is gone; an immediate
// restart must not race the removal and revive it.
if new_path != current_path {
let id_for_discard = id.clone();
let _ = tokio::task::spawn_blocking(move || {
crate::session::worktree_edit::discard_sandbox_container_after_move(
&id_for_discard,
is_sandboxed,
)
})
.await;
}
// The git move has already landed, so persist to disk BEFORE mutating
// in-memory state. A silent persist failure here would leave stale
// metadata that points at the old (now-moved) path after a daemon
@@ -2834,7 +2748,7 @@ pub async fn force_smart_rename(
return resp;
}
let Some((profile, tool, command, project_path, sandboxed, title, structured)) = ({
let Some((profile, tool, command, project_path, title, structured)) = ({
let instances = state.instances.read().await;
instances.iter().find(|i| i.id == id).map(|i| {
(
@@ -2842,7 +2756,6 @@ pub async fn force_smart_rename(
i.tool.clone(),
i.command.clone(),
i.project_path.clone(),
i.is_sandboxed(),
i.title.clone(),
i.is_structured(),
)
@@ -2853,8 +2766,8 @@ pub async fn force_smart_rename(
// Preflight the SAME gate the spawned try_smart_rename re-applies, so the
// action never reports success (202) for a session the gate would silently
// drop (a resolved rename agent with no one-shot, an overridden command, or
// a sandboxed session whose rename agent is not its own). Without this, the
// drop (a resolved rename agent with no one-shot, or an overridden
// command). Without this, the
// sidebar would show success while no title job runs. Resolves with the SAME repo-aware config the worker
// uses (resolve_config_with_repo_or_warn), so a repo-local smart_rename_agent
// or agent_command_override cannot make the preflight and worker disagree.
@@ -2872,7 +2785,6 @@ pub async fn force_smart_rename(
&title,
&tool,
&config.smart_rename_agent,
sandboxed,
&command,
&config.agent_command_override,
) {
@@ -2890,50 +2802,6 @@ pub async fn force_smart_rename(
.into_response();
}
// A sandboxed session's one-shot runs inside its container, so a stopped
// container is the one remaining way the spawned job would drop the session
// after the static gate passed. Probe it here too, else this would answer 202
// while nothing renames, which is exactly what the gate above exists to
// prevent. Same check and wording as the TUI's preflight; the spawned
// try_smart_rename re-probes and stays the authority.
if sandboxed {
use crate::containers::Probe;
let sid = id.clone();
let probe = tokio::task::spawn_blocking(move || {
crate::containers::DockerContainer::from_session_id(&sid).probe_running()
})
.await;
// A failed inspection is not a stopped container: telling the user to
// start a container that may already be running sends them the wrong
// way, so the runtime error is surfaced as its own state. Same split as
// the TUI preflight.
let unknown = match probe {
Ok(Probe::Running) => None,
Ok(Probe::NotRunning) => {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "container_not_running",
"message": "The session's sandbox container is not running, so its agent cannot be asked for a name. Open the session to start it, then try again.",
})),
)
.into_response();
}
Ok(Probe::Unknown(e)) => Some(e.to_string()),
Err(e) => Some(e.to_string()),
};
if let Some(err) = unknown {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"error": "container_state_unknown",
"message": format!("Couldn't check the session's sandbox container, so its agent cannot be asked for a name: {err}"),
})),
)
.into_response();
}
}
let Some((first_user_prompt, agent_prose)) = state
.acp_event_store
.first_turn_context(&id, crate::session::smart_rename::FIRST_TURN_AGENT_BYTES)
@@ -3414,8 +3282,6 @@ pub struct DeleteSessionBody {
#[serde(default)]
pub delete_branch: bool,
#[serde(default)]
pub delete_sandbox: bool,
#[serde(default)]
pub force_delete: bool,
/// For scratch sessions, keep the scratch directory on disk instead of
/// removing it. The session record is still deleted. No effect on
@@ -3467,7 +3333,6 @@ async fn purge_session_artifacts(
instance: instance.clone(),
delete_worktree: body.delete_worktree,
delete_branch: body.delete_branch,
delete_sandbox: body.delete_sandbox,
force_delete: body.force_delete,
detach_hooks: true,
keep_scratch: body.keep_scratch,
@@ -3742,7 +3607,6 @@ pub(crate) async fn purge_expired_trash(state: &Arc<AppState>) {
let body = DeleteSessionBody {
delete_worktree: cfg.worktree.auto_cleanup,
delete_branch: cfg.worktree.should_delete_branch_on_cleanup(),
delete_sandbox: cfg.sandbox.auto_cleanup,
force_delete: true,
keep_scratch: false,
};
@@ -3873,8 +3737,6 @@ pub struct DeleteWorkspaceBody {
#[serde(default)]
pub delete_branch: bool,
#[serde(default)]
pub delete_sandbox: bool,
#[serde(default)]
pub force_delete: bool,
#[serde(default)]
pub keep_scratch: bool,
@@ -3921,14 +3783,12 @@ fn order_workspace_deletion(
let sibling_body = DeleteSessionBody {
delete_worktree: false,
delete_branch: false,
delete_sandbox: body.delete_sandbox,
force_delete: body.force_delete,
keep_scratch: body.keep_scratch,
};
let owner_body = DeleteSessionBody {
delete_worktree: body.delete_worktree,
delete_branch: body.delete_branch,
delete_sandbox: body.delete_sandbox,
force_delete: body.force_delete,
keep_scratch: body.keep_scratch,
};
@@ -4212,14 +4072,8 @@ pub struct CreateSessionBody {
#[serde(default)]
pub base_branch: Option<String>,
#[serde(default)]
pub sandbox: bool,
#[serde(default)]
pub extra_args: String,
#[serde(default)]
pub sandbox_image: Option<String>,
#[serde(default)]
pub extra_env: Vec<String>,
#[serde(default)]
pub extra_repo_paths: Vec<String>,
/// Base branch for individual repos, as `{ repo, base_branch }` entries.
/// `repo` is a repo directory name or one of the paths in `path` /
@@ -4229,8 +4083,6 @@ pub struct CreateSessionBody {
pub repo_bases: Vec<RepoBaseInput>,
#[serde(default)]
pub command_override: String,
#[serde(default)]
pub custom_instruction: Option<String>,
pub profile: Option<String>,
/// How the new session should render: `structured` or `terminal`. The
/// bundled wizard sends an explicit value (`structured` for ACP-capable
@@ -4611,26 +4463,12 @@ pub(crate) fn run_create_hooks(
let (progress_tx, progress_rx) = std::sync::mpsc::channel::<repo_config::HookProgress>();
drop(progress_rx);
if instance.sandbox_info.is_some() {
instance.get_container_for_instance()?;
let workdir = instance.container_workdir();
if let Some(sandbox) = instance.sandbox_info.as_ref() {
repo_config::execute_hooks_in_container_streamed(
&plan.on_create,
&sandbox.container_name,
&workdir,
&progress_tx,
&hook_env,
)?;
}
} else {
repo_config::execute_hooks_streamed(
&plan.on_create,
std::path::Path::new(&instance.project_path),
&progress_tx,
&hook_env,
)?;
}
repo_config::execute_hooks_streamed(
&plan.on_create,
std::path::Path::new(&instance.project_path),
&progress_tx,
&hook_env,
)?;
Ok(())
}
@@ -5072,10 +4910,7 @@ pub async fn create_session(
worktree_branch: body.worktree_branch,
create_new_branch: body.create_new_branch,
base_branch: body.base_branch,
sandbox: body.sandbox,
sandbox_image: body.sandbox_image,
yolo_mode: body.yolo_mode,
extra_env: body.extra_env,
extra_args: body.extra_args,
command_override: body.command_override,
extra_repo_paths: body.extra_repo_paths,
@@ -5086,7 +4921,6 @@ pub async fn create_session(
.collect(),
scratch: body.scratch,
trust_hooks: body.trust_hooks,
custom_instruction: body.custom_instruction,
callback_url: body.callback_url,
idempotency_key: body.idempotency_key,
profile,
@@ -5618,99 +5452,6 @@ pub async fn ensure_terminal(
}
}
pub async fn ensure_container_terminal(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
axum::extract::Query(q): axum::extract::Query<crate::server::live_ws::TerminalIndexQuery>,
) -> impl IntoResponse {
if state.read_only {
return super::read_only_response();
}
let index = q.index;
if index > crate::server::pane::MAX_TERMINAL_INDEX {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "index_out_of_range"})),
)
.into_response();
}
let instances = state.instances.read().await;
let inst = match instances.iter().find(|i| i.id == id) {
Some(i) => i.clone(),
None => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "not_found"})),
)
.into_response();
}
};
drop(instances);
let inst_lock = state.instance_lock(&id).await;
let _guard = inst_lock.lock().await;
// Same dead-pane rescue as `ensure_terminal`: an existing-but-dead
// pane would otherwise silently swallow every keystroke from the
// browser. Container terminals are always tmux-queried (no cache flag).
{
let instances = state.instances.read().await;
if let Some(i) = instances.iter().find(|i| i.id == id) {
let session = i.container_terminal_tmux_session_indexed(index).ok();
if session.as_ref().map(|s| s.exists()).unwrap_or(false) {
let pane_dead = session
.map(|s| s.exists() && s.is_pane_dead())
.unwrap_or(false);
if !pane_dead {
return (
StatusCode::OK,
Json(serde_json::json!({"status": "exists"})),
)
.into_response();
}
tracing::warn!(
target: "terminal.ws",
session = %id,
index,
"container terminal pane is dead, respawning"
);
}
}
}
let mut inst_clone = inst;
let result = tokio::task::spawn_blocking(move || {
let _ = inst_clone.kill_container_terminal_if_dead_indexed(index);
inst_clone.start_container_terminal_with_size_indexed(index, None)
})
.await;
match result {
Ok(Ok(())) => (
StatusCode::CREATED,
Json(serde_json::json!({"status": "created"})),
)
.into_response(),
Ok(Err(e)) => {
tracing::error!(target: "http.api.sessions", "Container terminal creation failed: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "create_failed", "message": "Failed to create container terminal"})),
)
.into_response()
}
Err(e) => {
tracing::error!(target: "http.api.sessions", "Container terminal creation panicked: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "internal", "message": "Internal server error"})),
)
.into_response()
}
}
}
/// Kill an additional paired terminal (host + container) at `index`. Used when
/// the web dashboard closes an extra terminal tab so its tmux shell does not
/// leak for the session's lifetime. Index 0 is the primary terminal shared with
@@ -5753,7 +5494,6 @@ pub async fn kill_terminal(
// tmux session is absent); only a real tmux failure surfaces here, so
// the caller can retry instead of leaving an orphaned shell behind.
inst.kill_terminal_indexed(index)?;
inst.kill_container_terminal_indexed(index)?;
Ok(())
})
.await;
@@ -5896,91 +5636,6 @@ pub async fn session_file(
}
}
#[derive(Deserialize)]
pub struct VolumeIgnoresPreviewQuery {
pub path: String,
#[serde(default)]
pub profile: Option<String>,
}
#[derive(Serialize)]
pub struct VolumeIgnoresGlobPreview {
pub pattern: String,
pub matched_paths: Vec<String>,
}
#[derive(Serialize)]
pub struct VolumeIgnoresPreviewResponse {
/// True once the user has acknowledged the snapshot-expansion behavior, so
/// the wizard can skip the confirm modal without another round trip.
pub acknowledged: bool,
/// One entry per glob `volume_ignores` pattern with the directories it
/// currently matches (container-side paths). Empty when none are configured.
pub globs: Vec<VolumeIgnoresGlobPreview>,
}
/// Dry-run how glob `volume_ignores` entries would expand for a session rooted at
/// `path`, without creating anything. The wizard calls this before a sandbox
/// create to decide whether to show the snapshot-expansion confirm modal (#2045).
/// Read-only: no `read_only` guard needed.
pub async fn preview_volume_ignores_globs(
axum::extract::Query(query): axum::extract::Query<VolumeIgnoresPreviewQuery>,
) -> impl IntoResponse {
let result = tokio::task::spawn_blocking(move || {
let profile = query.profile.unwrap_or_default();
let config = crate::session::repo_config::resolve_config_with_repo(
&profile,
std::path::Path::new(&query.path),
)?;
let expansions = crate::session::container_config::preview_glob_volume_ignores(
&query.path,
None,
&config.sandbox.volume_ignores,
)?;
let acknowledged = crate::session::Config::load()
.map(|c| c.app_state.has_acknowledged_volume_ignores_globs)
.unwrap_or(false);
Ok::<_, anyhow::Error>((acknowledged, expansions))
})
.await;
match result {
Ok(Ok((acknowledged, expansions))) => {
let globs = expansions
.into_iter()
.map(|e| VolumeIgnoresGlobPreview {
pattern: e.pattern,
matched_paths: e.matched_container_paths,
})
.collect();
(
StatusCode::OK,
Json(VolumeIgnoresPreviewResponse {
acknowledged,
globs,
}),
)
.into_response()
}
Ok(Err(e)) => {
tracing::warn!(target: "http.api.sessions", "volume_ignores glob preview failed: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "preview_failed", "message": "Failed to preview volume_ignores"})),
)
.into_response()
}
Err(e) => {
tracing::error!(target: "http.api.sessions", "volume_ignores glob preview panicked: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "internal", "message": "Internal server error"})),
)
.into_response()
}
}
}
#[derive(Deserialize)]
pub struct SearchQuery {
pub q: String,
@@ -6337,7 +5992,6 @@ mod tests {
session_ids: vec![],
delete_worktree: true,
delete_branch: true,
delete_sandbox: true,
force_delete: false,
keep_scratch: false,
}
@@ -6362,10 +6016,6 @@ mod tests {
"sibling {id} must not remove the worktree"
);
assert!(!b.delete_branch, "sibling {id} must not delete the branch");
assert!(
b.delete_sandbox,
"sibling {id} still tears down its own sandbox"
);
}
// The owner (last) carries the caller's worktree/branch flags.
let (owner_id, owner_body) = plan.last().unwrap();
@@ -7190,34 +6840,6 @@ mod tests {
);
}
#[test]
#[serial_test::serial(hook_base)]
fn from_instance_surfaces_hook_urgent_flag() {
// #1640: the web Attention sort needs `Instance::is_urgent()` on the
// wire. Write the hook-side attention.json the agent would emit and
// confirm it round-trips onto the response, then confirm a session
// with no hook file reports urgent: false.
let (_g, _, _tmp_base) = crate::hooks::test_support::BaseGuard::ready();
let inst = make_test_instance();
let dir = crate::hooks::ensure_instance_dir_path(&inst.id)
.expect("guard must create instance subdir");
std::fs::write(
dir.join("attention.json"),
r#"{"urgent":true,"urgent_reason":"needs input"}"#,
)
.unwrap();
let urgent_resp = SessionResponse::from_instance(&inst, false);
assert!(urgent_resp.urgent, "hook-flagged session must be urgent");
crate::hooks::cleanup_hook_status_dir(&inst.id);
let plain_resp = SessionResponse::from_instance(&inst, false);
assert!(
!plain_resp.urgent,
"session with no hook file must not be urgent"
);
}
#[test]
fn public_create_session_error_forwards_whitelisted_git_errors() {
let dup: anyhow::Error =
@@ -7270,21 +6892,6 @@ mod tests {
);
}
#[test]
fn session_response_from_instance() {
let inst = make_test_instance();
let resp = SessionResponse::from_instance(&inst, false);
assert_eq!(resp.id, inst.id);
assert_eq!(resp.title, "test-session");
assert_eq!(resp.project_path, "/tmp/test-project");
assert_eq!(resp.tool, "claude");
assert_eq!(resp.status, "Running");
assert_eq!(resp.group_path, "work/projects");
assert!(!resp.is_sandboxed);
assert!(!resp.has_terminal);
}
#[test]
fn session_response_status_variants() {
let mut inst = make_test_instance();
@@ -9209,15 +8816,7 @@ fn write_paste_image(
/// panes share the host filesystem, so the absolute host path is correct. A
/// sandboxed pane mounts the worktree under a container path (`/workspace/...`);
/// reuse `compute_volume_paths` so the pasted path matches that mount.
fn pane_visible_paste_path(project_path: &str, is_sandboxed: bool, file_name: &str) -> String {
if is_sandboxed {
if let Ok((_, working_dir)) = crate::session::container_config::compute_volume_paths(
std::path::Path::new(project_path),
project_path,
) {
return format!("{working_dir}/{PASTE_IMAGE_DIR}/{file_name}");
}
}
fn pane_visible_paste_path(project_path: &str, file_name: &str) -> String {
std::path::Path::new(project_path)
.join(PASTE_IMAGE_DIR)
.join(file_name)
@@ -9287,7 +8886,6 @@ pub async fn paste_image(
let ext = paste_image_extension(mime);
let project_path = instance.project_path.clone();
let is_sandboxed = instance.is_sandboxed();
let write_project = project_path.clone();
let (host_path, file_name) =
match tokio::task::spawn_blocking(move || write_paste_image(&write_project, &bytes, ext))
@@ -9320,7 +8918,7 @@ pub async fn paste_image(
let _ = tokio::fs::remove_file(&host_path).await;
});
let pane_path = pane_visible_paste_path(&project_path, is_sandboxed, &file_name);
let pane_path = pane_visible_paste_path(&project_path, &file_name);
(
StatusCode::OK,
Json(serde_json::json!({ "path": pane_path })),
@@ -9460,7 +9058,6 @@ mod workspace_ordering_tests {
branch: branch.map(str::to_string),
main_repo_path: None,
base_branch: None,
is_sandboxed: false,
scratch: false,
has_managed_worktree: false,
has_cleanable_worktree: false,
@@ -9472,7 +9069,6 @@ mod workspace_ordering_tests {
cleanup_defaults: CleanupDefaults {
delete_worktree: false,
delete_branch: false,
delete_sandbox: false,
delete_to_trash: true,
},
trashed_at: None,
@@ -9765,7 +9361,7 @@ mod paste_image_tests {
let dir = tempdir().unwrap();
let project = dir.path().to_string_lossy().to_string();
let pane = pane_visible_paste_path(&project, false, "aoe-paste-x.png");
let pane = pane_visible_paste_path(&project, "aoe-paste-x.png");
let expected = dir
.path()
@@ -9775,25 +9371,4 @@ mod paste_image_tests {
.to_string();
assert_eq!(pane, expected);
}
#[test]
fn sandboxed_pane_path_uses_container_mount() {
let dir = tempdir().unwrap();
let project = dir.path().to_string_lossy().to_string();
let dir_name = dir
.path()
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
let pane = pane_visible_paste_path(&project, true, "aoe-paste-x.png");
// A non-git worktree mounts under /workspace/<dir-name>; the pasted
// path must be the container-visible path, not the host path.
assert_eq!(
pane,
format!("/workspace/{dir_name}/{PASTE_IMAGE_DIR}/aoe-paste-x.png")
);
}
}
-84
View File
@@ -640,56 +640,6 @@ pub async fn patch_web_ui_state(
}
}
/// Records that the user has acknowledged glob `volume_ignores` snapshot
/// expansion (#2045), so the new-session wizard's confirm modal is shown once
/// and never again. Single-purpose write mirroring [`dismiss_update`]: it
/// flips one bool, grants no capability, stays exempt from the elevation wall,
/// and `read_only` still blocks it.
pub async fn mark_volume_ignores_globs_acknowledged(
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
if state.read_only {
return (
StatusCode::FORBIDDEN,
Json(
serde_json::json!({"error": "read_only", "message": "Server is in read-only mode"}),
),
)
.into_response();
}
let result = tokio::task::spawn_blocking(|| {
crate::session::update_app_state(|state| {
state.has_acknowledged_volume_ignores_globs = true;
})
})
.await;
match result {
Ok(Ok(())) => (
StatusCode::OK,
Json(serde_json::json!({"has_acknowledged_volume_ignores_globs": true})),
)
.into_response(),
Ok(Err(e)) => {
tracing::warn!(target: "http.api.system", "Marking volume_ignores globs acknowledged failed: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "save_failed", "message": "Failed to persist acknowledgment"})),
)
.into_response()
}
Err(e) => {
tracing::error!(target: "http.api.system", "Marking volume_ignores globs acknowledged panicked: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "internal", "message": "Internal server error"})),
)
.into_response()
}
}
}
// --- Themes ---
pub async fn list_themes() -> Json<Vec<String>> {
@@ -985,40 +935,6 @@ pub async fn list_groups(State(state): State<Arc<AppState>>) -> impl IntoRespons
Json(groups)
}
#[derive(Serialize)]
pub struct DockerStatus {
pub available: bool,
pub runtime: Option<String>,
}
pub async fn docker_status() -> Json<DockerStatus> {
let result = tokio::task::spawn_blocking(|| {
let runtime = crate::containers::get_container_runtime();
let available = runtime.is_available() && runtime.is_daemon_running();
let runtime_name = if available {
let config = crate::session::Config::load_or_warn();
Some(
serde_json::to_value(config.sandbox.container_runtime)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_else(|| "docker".to_string()),
)
} else {
None
};
DockerStatus {
available,
runtime: runtime_name,
}
})
.await
.unwrap_or(DockerStatus {
available: false,
runtime: None,
});
Json(result)
}
/// Read-only runtime view of the `aoe serve` daemon's sleep-inhibit reconciler.
/// Derived from a snapshot the poll loop publishes plus the live backend latch;
/// never a control surface.
-22
View File
@@ -355,28 +355,6 @@ pub async fn live_paired_terminal_ws(
.await
}
/// Live view for the paired in-container shell.
pub async fn live_container_terminal_ws(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Query(q): Query<TerminalIndexQuery>,
) -> impl IntoResponse {
live_shell_ws(
ws,
state,
id,
q.index,
"container-live",
|state, id, inst, index| {
Box::pin(super::pane::respawn_container_if_dead(
state, id, inst, index,
))
},
)
.await
}
type RespawnFn = for<'a> fn(
&'a Arc<AppState>,
&'a str,
+2 -19
View File
@@ -981,10 +981,6 @@ fn build_router(state: Arc<AppState>) -> Router {
"/api/sessions/{id}/terminal",
post(api::ensure_terminal).delete(api::kill_terminal),
)
.route(
"/api/sessions/{id}/container-terminal",
post(api::ensure_container_terminal),
)
// Agents
.route("/api/agents", get(api::list_agents))
// Profiles
@@ -1013,7 +1009,6 @@ fn build_router(state: Arc<AppState>) -> Router {
"/api/projects/{name}",
patch(api::update_project).delete(api::delete_project),
)
.route("/api/docker/status", get(api::docker_status))
// Settings + themes
.route(
"/api/settings",
@@ -1026,14 +1021,6 @@ fn build_router(state: Arc<AppState>) -> Router {
"/api/app-state/web-ui-state",
get(api::get_web_ui_state).patch(api::patch_web_ui_state),
)
.route(
"/api/app-state/volume-ignores-globs-acknowledged",
post(api::mark_volume_ignores_globs_acknowledged),
)
.route(
"/api/sandbox/volume-ignores-preview",
get(api::preview_volume_ignores_globs),
)
.route("/api/themes", get(api::list_themes))
.route("/api/themes/{name}", get(api::get_resolved_theme))
.route("/api/theme/current", get(api::get_current_theme))
@@ -1061,10 +1048,6 @@ fn build_router(state: Arc<AppState>) -> Router {
.route(
"/sessions/{id}/terminal/live-ws",
get(live_ws::live_paired_terminal_ws),
)
.route(
"/sessions/{id}/container-terminal/live-ws",
get(live_ws::live_container_terminal_ws),
);
#[cfg(feature = "serve")]
@@ -1797,7 +1780,7 @@ fn is_content_hashed_asset(path: &str) -> bool {
}
/// Kind tag for a local IPv4 address. Ordering in this enum is also the
/// preference order for picking the "primary" URL to show in a QR: when
/// preference order for picking the "primary" URL to display: when
/// the user serves on a Tailnet, that's almost always the one they want
/// a phone (on cellular) to scan, not the LAN IP behind their NAT.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
@@ -7690,7 +7673,7 @@ mod tests {
#[test]
fn ip_kind_ordering_prefers_tailscale() {
// This is the "Tailscale first in QR" contract. If the sort order
// This is the "Tailscale first" contract. If the sort order
// ever flips, the user's phone would scan a LAN IP from cellular
// and hit a timeout — regression test locks it in.
let mut v = [IpKind::Loopback, IpKind::Lan, IpKind::Tailscale];
-52
View File
@@ -116,58 +116,6 @@ pub(crate) async fn respawn_paired_if_dead(
Ok(tmux_name)
}
/// Container-terminal counterpart of [`respawn_paired_if_dead`].
pub(crate) async fn respawn_container_if_dead(
state: &Arc<AppState>,
id: &str,
inst: &crate::session::Instance,
index: u32,
) -> anyhow::Result<String> {
let tmux_name =
crate::tmux::ContainerTerminalSession::resolve_name_indexed(&inst.id, &inst.title, index);
let lock = state.instance_lock(id).await;
let _guard = lock.lock().await;
let mut inst_for_blocking = inst.clone();
let tmux_name_clone = tmux_name.clone();
// No in-memory cache to update for container terminal: `has_container_terminal()`
// queries tmux directly, so unlike the paired variant we don't need to write
// back a `terminal_info` flag after a successful respawn.
//
// See `respawn_paired_if_dead` for the missing-session branch: a
// `tmux kill-session` on a paired container terminal also has to
// recreate from scratch, not just kill-then-respawn the pane.
let _respawned = tokio::task::spawn_blocking(move || -> anyhow::Result<bool> {
let killed_dead = inst_for_blocking.kill_container_terminal_if_dead_indexed(index)?;
let session_missing = !inst_for_blocking
.container_terminal_tmux_session_indexed(index)?
.exists();
if !killed_dead && !session_missing {
return Ok(false);
}
if killed_dead {
tracing::warn!(
target: "terminal.ws",
tmux = %tmux_name_clone,
"container terminal pane dead at WS upgrade, killing and respawning"
);
} else {
tracing::warn!(
target: "terminal.ws",
tmux = %tmux_name_clone,
"container terminal session missing at WS upgrade, recreating"
);
}
inst_for_blocking.start_container_terminal_with_size_indexed(index, None)?;
Ok(true)
})
.await
.map_err(|e| anyhow::anyhow!("respawn task panicked: {e}"))??;
Ok(tmux_name)
}
/// Send a close frame on a socket we're about to drop before the main loop.
pub(crate) async fn close_early(socket: &mut WebSocket, code: u16, reason: &'static str) {
let _ = socket
-50
View File
@@ -26,10 +26,7 @@ pub(crate) struct StructuredSessionSpec {
pub worktree_branch: Option<String>,
pub create_new_branch: bool,
pub base_branch: Option<String>,
pub sandbox: bool,
pub sandbox_image: Option<String>,
pub yolo_mode: bool,
pub extra_env: Vec<String>,
pub extra_args: String,
pub command_override: String,
pub extra_repo_paths: Vec<String>,
@@ -37,7 +34,6 @@ pub(crate) struct StructuredSessionSpec {
pub repo_base_branches: Vec<(String, String)>,
pub scratch: bool,
pub trust_hooks: Option<bool>,
pub custom_instruction: Option<String>,
/// External work-queue dispatcher completion callback, persisted onto
/// the created instance. See #3156.
pub callback_url: Option<String>,
@@ -109,7 +105,6 @@ pub(crate) async fn spawn_structured_session(
let result = tokio::task::spawn_blocking(move || {
use crate::session::builder::{self, InstanceParams};
use crate::session::Config;
use crate::session::Storage;
let StructuredSessionSpec {
@@ -121,17 +116,13 @@ pub(crate) async fn spawn_structured_session(
worktree_branch,
create_new_branch,
base_branch,
sandbox,
sandbox_image,
yolo_mode,
extra_env,
extra_args,
command_override,
extra_repo_paths,
repo_base_branches,
scratch,
trust_hooks,
custom_instruction,
callback_url,
idempotency_key,
profile,
@@ -151,15 +142,6 @@ pub(crate) async fn spawn_structured_session(
fork_seed,
} = spec;
let config = Config::load_or_warn();
let sandbox_image = sandbox_image.unwrap_or_else(|| {
if config.sandbox.default_image.is_empty() {
"ubuntu:latest".to_string()
} else {
config.sandbox.default_image.clone()
}
});
let title_refs: Vec<&str> = existing_titles.iter().map(|s| s.as_str()).collect();
let branch_refs: Vec<&str> = existing_branches.iter().map(|s| s.as_str()).collect();
let extra_repo_paths: Vec<String> = extra_repo_paths
@@ -203,10 +185,7 @@ pub(crate) async fn spawn_structured_session(
} else {
None
},
sandbox,
sandbox_image,
yolo_mode,
extra_env,
extra_args,
command_override,
extra_repo_paths,
@@ -235,13 +214,6 @@ pub(crate) async fn spawn_structured_session(
let created_worktree = build_result.created_worktree;
let created_workspace_worktrees = build_result.created_workspace_worktrees;
// Apply per-session sandbox overrides from the request body.
if let Some(ref mut sandbox) = instance.sandbox_info {
if custom_instruction.is_some() {
sandbox.custom_instruction = custom_instruction;
}
}
// Apply structured-view fields from the request body. structured_view is
// re-validated below against real ACP capability; non-ACP tools
// fall back to terminal view rather than erroring at spawn time.
@@ -514,27 +486,6 @@ pub(crate) async fn spawn_structured_session(
.is_some_and(|i| i.pending_initial_turn.is_some())
};
tokio::spawn(async move {
let inst_lock = service_for_check.instance_lock(&id).await;
let sandbox_info = match crate::acp::sandbox::ensure_container_for_session(
&service_for_check.instances,
&inst_lock,
&id,
true,
)
.await
{
Ok(info) => info,
Err(e) => {
let message = format!("sandbox container ensure failed: {e}");
tracing::warn!(
target: "acp.supervisor",
session = %id,
"auto-spawn after create failed: {message}"
);
supervisor.publish_startup_error(&id, message);
return;
}
};
let source_profile_for_spawn = Some(source_profile.clone());
match supervisor
.spawn(crate::acp::supervisor::SpawnRequest {
@@ -548,7 +499,6 @@ pub(crate) async fn spawn_structured_session(
effort,
stored_acp_session_id,
fork_from,
sandbox_info,
source_profile: source_profile_for_spawn,
yolo_mode,
acp_mode_id,
+5 -190
View File
@@ -11,13 +11,10 @@ use std::{
use anyhow::{bail, Result};
use chrono::Utc;
use crate::containers;
use crate::git::error::GitError;
use crate::git::GitWorktree;
use super::{
civilizations, Config, Instance, SandboxInfo, WorkspaceInfo, WorkspaceRepo, WorktreeInfo,
};
use super::{civilizations, Config, Instance, WorkspaceInfo, WorkspaceRepo, WorktreeInfo};
/// Parameters for creating a new session instance.
#[derive(Debug, Clone)]
@@ -33,13 +30,7 @@ pub struct InstanceParams {
/// when `create_new_branch` is true. `None` falls back to the
/// repository's detected default branch. See #948.
pub base_branch: Option<String>,
pub sandbox: bool,
/// The sandbox image to use. Required when sandbox is true.
pub sandbox_image: String,
pub yolo_mode: bool,
/// Additional environment entries for the container.
/// `KEY` = pass through from host, `KEY=VALUE` = set explicitly.
pub extra_env: Vec<String>,
/// Extra arguments to append after the agent binary
pub extra_args: String,
/// Command override for the agent binary (replaces the default binary)
@@ -456,14 +447,8 @@ pub fn build_instance(
existing_branches: &[&str],
profile: &str,
) -> Result<BuildResult> {
// Host-only agents (e.g. settl) cannot run in a sandbox or use worktrees.
// Host-only agents (e.g. settl) cannot use worktrees.
let is_host_only = crate::agents::get_agent(&params.tool).is_some_and(|a| a.host_only);
if is_host_only && params.sandbox {
bail!(
"{} can only run on the host, not in a sandbox.",
params.tool
);
}
if is_host_only && params.worktree_enabled {
bail!("{} does not support worktree mode.", params.tool);
}
@@ -477,16 +462,6 @@ pub fn build_instance(
}
}
if params.sandbox {
let runtime = containers::get_container_runtime();
if !runtime.is_available() {
bail!("Container runtime is not installed. Please install a supported runtime to use sandbox mode.");
}
if !runtime.is_daemon_running() {
bail!("Container runtime daemon is not running. Please start a supported runtime to use sandbox mode.");
}
}
// Scratch sessions have no project repo, so config resolution falls
// back to global+profile defaults (`Path::new("")` makes
// `resolve_config_with_repo` skip the repo-config layer cleanly).
@@ -788,34 +763,6 @@ pub fn build_instance(
}
}
if params.sandbox {
// Surface env-resolution warnings up-front. `collect_environment`
// silently drops entries whose host source var is unset (typo,
// shell sourcing gap, daemon's frozen env). Without this check
// the value is missing in the container with no UI signal.
let effective_env: &[String] = if params.extra_env.is_empty() {
&config.sandbox.environment
} else {
&params.extra_env
};
warnings.extend(crate::session::validate_env_entries(effective_env));
instance.sandbox_info = Some(SandboxInfo {
enabled: true,
container_id: None,
image: params.sandbox_image.clone(),
container_name: containers::DockerContainer::generate_name(&instance.id),
extra_env: if params.extra_env.is_empty() {
None
} else {
Some(params.extra_env.clone())
},
custom_instruction: config.sandbox.custom_instruction.clone(),
before_start_env: Vec::new(),
container_workdir: None,
});
}
if let Some(seed) = params.fork_seed {
match seed {
crate::session::ForkSeed::Terminal {
@@ -972,7 +919,7 @@ fn cleanup_created_worktree(
/// Clean up resources created during a failed or cancelled instance build.
///
/// Runtime resources always belong to the losing build and are stopped first,
/// so a sandbox bind mount cannot block worktree removal. `protected_owner`
/// `protected_owner`
/// is the persisted row that won a final uniqueness race; filesystem and Git
/// resources referenced by that row are retained.
pub fn cleanup_instance(
@@ -987,22 +934,10 @@ pub fn cleanup_instance(
// path's only extra threads are the scoped worktree-creation threads that
// join before `build_instance` returns, and the async creation poller's
// build thread has already finished delivering its result before a rollback
// reaches this helper. So the tmux (below) and container (further down)
// teardown reclaim every runtime resource with no thread left running.
// reaches this helper. So the tmux teardown below reclaims every runtime
// resource with no thread left running.
instance.kill_all_tmux_sessions_without_lifecycle_row();
if let Some(sandbox) = &instance.sandbox_info {
if sandbox.enabled {
// Direct idempotent teardown, never gated on a separate existence
// probe. This must precede filesystem cleanup because the
// container bind-mounts the worktree.
let container = containers::DockerContainer::from_session_id(&instance.id);
if let containers::Teardown::Failed(e) = container.teardown(&instance.id) {
tracing::warn!(target: "session.create", "Failed to clean up container: {}", e);
}
}
}
let protection = CleanupProtection {
owner: protected_owner,
};
@@ -2201,10 +2136,7 @@ mod tests {
worktree_branch: None,
create_new_branch: false,
base_branch: None,
sandbox: false,
sandbox_image: "ubuntu:latest".to_string(),
yolo_mode: false,
extra_env: Vec::new(),
extra_args: String::new(),
command_override: String::new(),
extra_repo_paths: Vec::new(),
@@ -2340,123 +2272,6 @@ mod tests {
);
}
#[test]
#[serial_test::serial]
fn build_instance_scratch_provisions_app_dir() {
let temp_home = tempfile::tempdir().unwrap();
std::env::set_var("HOME", temp_home.path());
let app_dir = isolated_app_dir(temp_home.path());
std::fs::create_dir_all(&app_dir).unwrap();
std::fs::write(app_dir.join("config.toml"), "").unwrap();
let mut params = custom_agent_params(std::path::Path::new(""), "claude");
params.tool = "claude".to_string();
params.path = String::new();
params.scratch = true;
params.sandbox = false;
let result = build_instance(params, &[], &[], "default")
.expect("scratch build must succeed without a project path");
assert!(
result.instance.scratch,
"scratch flag must be persisted on the instance"
);
let provisioned = std::path::PathBuf::from(&result.instance.project_path);
assert!(provisioned.exists());
assert!(super::super::scratch::is_scratch_path(&provisioned));
let _ = std::fs::remove_dir_all(&provisioned);
}
#[test]
fn build_instance_applies_terminal_fork_seed() {
use crate::session::ForkSeed;
let params = InstanceParams {
title: "Forked".into(),
path: "/tmp".into(),
group: String::new(),
tool: "claude".into(),
worktree_enabled: false,
worktree_branch: None,
create_new_branch: false,
base_branch: None,
sandbox: false,
sandbox_image: String::new(),
yolo_mode: false,
extra_env: vec![],
extra_args: String::new(),
command_override: String::new(),
extra_repo_paths: vec![],
repo_base_branches: Vec::new(),
scratch: false,
fork_seed: Some(ForkSeed::Terminal {
parent_agent_session_id: "parent-uuid".into(),
child_session_id: "child-uuid".into(),
}),
};
let inst = build_instance(params, &[], &[], "default")
.unwrap()
.instance;
// The pre-pinned child id lives in agent_session_id; the parent rides on
// the one-shot Fork intent.
assert_eq!(inst.agent_session_id.as_deref(), Some("child-uuid"));
assert!(
matches!(
inst.resume_intent,
crate::session::instance::ResumeIntent::Fork { ref from } if from == "parent-uuid"
),
"fork intent must carry the parent id in `from`, got {:?}",
inst.resume_intent
);
}
#[cfg(feature = "serve")]
#[test]
fn build_instance_applies_structured_fork_seed() {
use crate::session::ForkSeed;
let params = InstanceParams {
title: "Forked".into(),
path: "/tmp".into(),
group: String::new(),
tool: "claude".into(),
worktree_enabled: false,
worktree_branch: None,
create_new_branch: false,
base_branch: None,
sandbox: false,
sandbox_image: String::new(),
yolo_mode: false,
extra_env: vec![],
extra_args: String::new(),
command_override: String::new(),
extra_repo_paths: vec![],
repo_base_branches: Vec::new(),
scratch: false,
fork_seed: Some(ForkSeed::Structured {
parent_acp_session_id: "parent-acp-id".into(),
}),
};
let inst = build_instance(params, &[], &[], "default")
.unwrap()
.instance;
// The structured arm forces the structured view and sets the two paired
// one-shot markers: fork_pending carries the parent for session/fork,
// and import_pending replays history into the fresh event store. A
// regression on any of the three should fail here, not only in the
// aggregate structured e2e.
assert_eq!(inst.view, crate::session::View::Structured);
assert_eq!(inst.fork_pending.as_deref(), Some("parent-acp-id"));
assert_eq!(inst.import_pending, Some(true));
// Structured fork does not pre-pin an agent id (the adapter mints the
// child id at handshake) and leaves the terminal Fork intent unset.
assert!(inst.agent_session_id.is_none());
assert!(!matches!(
inst.resume_intent,
crate::session::instance::ResumeIntent::Fork { .. }
));
}
#[test]
#[serial_test::serial]
fn build_instance_rejects_scratch_with_worktree() {
+1 -772
View File
@@ -390,142 +390,6 @@ pub(crate) fn claude_poll_fn(
}
}
/// The `sh` snippet shipped to `docker exec` to list candidate transcripts.
///
/// All three checks dereference: `[ -f ]` for type, `find -L` for freshness,
/// `ls -tL` for ordering. A symlink's own mtime is frozen at creation, so a
/// link left undereferenced ages out of the five-minute gate while its target
/// is still being appended. The host scan reads through links for the same
/// reason (#3454), and the two have to agree for
/// `claude_host_transcript_confirmed_absent` to mean anything.
fn claude_container_list_snippet(dir_name: &str) -> String {
format!(
r#"
CLAUDE_HOME="${{CLAUDE_CONFIG_DIR:-$HOME/.claude}}"
DIR="$CLAUDE_HOME/projects/{dir_name}"
[ -d "$DIR" ] || exit 0
for f in $(ls -tL "$DIR"/*.jsonl 2>/dev/null); do
[ -f "$f" ] || continue
[ -n "$(find -L "$f" -mmin -5 2>/dev/null)" ] || continue
basename "$f" .jsonl
done
"#
)
}
/// Capture Claude Code session ID inside a Docker container.
///
/// Lists every fresh (≤ 5 min mtime) UUID-named jsonl in
/// `$CLAUDE_CONFIG_DIR/projects/<encoded-cwd>/` newest-first via
/// `docker exec`, wrapped in [`run_with_timeout`] (5 s) so a hung exec
/// cannot block the poller thread, then delegates per-pane attribution to
/// [`select_claude_session_in_container`].
pub(crate) fn capture_claude_session_id_in_container(
container_name: &str,
container_cwd: &str,
exclusion: &HashSet<String>,
known_session_id: Option<&str>,
) -> Result<String> {
let snippet = claude_container_list_snippet(&encode_claude_project_path(container_cwd));
let mut cmd = std::process::Command::new("docker");
cmd.args(["exec", container_name, "sh", "-c", &snippet]);
let stdout_bytes = run_with_timeout(
cmd,
Duration::from_secs(5),
"docker exec sh (claude jsonl scan)",
)
.map_err(|e| anyhow::anyhow!("{} (container {})", e, container_name))?;
select_claude_session_in_container(&stdout_bytes, exclusion, known_session_id)
.map_err(|e| anyhow::anyhow!("{} (container {})", e, container_name))
}
/// Pick the active Claude session UUID from the container shell snippet's
/// stdout.
///
/// Stdout is UUID basenames in newest-first order. Tie-break (mirrors
/// [`scan_claude_project_dir`]):
/// 1. anchor absent → return first unexcluded.
/// 2. anchor present, an unexcluded candidate appears before it → return
/// that candidate (active newer wins).
/// 3. otherwise → return the anchor.
fn select_claude_session_in_container(
stdout_bytes: &[u8],
exclusion: &HashSet<String>,
known: Option<&str>,
) -> Result<String> {
let text = String::from_utf8_lossy(stdout_bytes);
let candidates: Vec<String> = text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && Uuid::parse_str(l).is_ok())
.map(String::from)
.collect();
if candidates.is_empty() {
anyhow::bail!("No active Claude session found in container");
}
let known_pos = known.and_then(|k| {
if exclusion.contains(k) {
None
} else {
candidates.iter().position(|c| c == k)
}
});
let best_pos = candidates.iter().position(|c| !exclusion.contains(c));
match (known_pos, best_pos) {
(None, None) => {
anyhow::bail!("All Claude session candidates in container are excluded")
}
(None, Some(p)) => Ok(candidates[p].clone()),
(Some(kp), Some(bp)) if bp < kp => Ok(candidates[bp].clone()),
(Some(kp), _) => Ok(candidates[kp].clone()),
}
}
/// Polling closure for sandboxed (Docker) Claude Code session tracking.
///
/// Mirrors [`claude_poll_fn`] but does not read the host hook sidecar (the
/// in-container hook would write to the container's `/tmp/aoe-hooks/`,
/// which the host poller cannot see without bind-mounting). Sandboxed
/// `/clear` adoption therefore takes ≤ 1 poll tick.
pub(crate) fn claude_poll_fn_sandboxed(
container_name: String,
container_cwd: String,
known_session_id: Option<String>,
instance_id: String,
extra_excludes: HashSet<String>,
) -> impl Fn() -> Option<String> + Send + 'static {
let last_known = std::sync::Mutex::new(known_session_id);
move || {
let current_known = last_known.lock().ok().and_then(|g| g.clone());
let exclusion = compose_exclusion(&instance_id, &extra_excludes);
let captured = capture_claude_session_id_in_container(
&container_name,
&container_cwd,
&exclusion,
current_known.as_deref(),
)
.map_err(
|e| tracing::debug!(target: "session.capture", "Claude container scan failed: {}", e),
)
.ok()
.and_then(validated_session_id);
if let Some(id) = captured.as_ref() {
if let Ok(mut guard) = last_known.lock() {
*guard = Some(id.clone());
}
}
captured
}
}
pub(crate) fn encode_pi_project_path(cwd: &str) -> String {
let stripped = cwd
.strip_prefix('/')
@@ -815,124 +679,6 @@ pub(crate) fn pi_poll_fn(
}
}
const PI_COMMAND_TIMEOUT_SECS: u64 = 5;
/// Shell snippet executed via `docker exec` to enumerate pi-family `.jsonl`
/// session files inside the container. Each file is emitted as a
/// `===PI:<unix-mtime>===` header followed by the file's `{"type":"session",...}`
/// record and a `===END===` trailer; the host parses this stream rather than
/// spawning one `docker exec head` per file.
///
/// `pi` writes that record on line 0, but `omp` (a pi fork) prefixes a
/// `{"type":"title",...}` record, so the session record can be on line 1. The
/// script scans the first 8 lines (mirroring `PI_HEADER_SCAN_LINES`) and emits
/// only the session line, matched via `grep -m1 '^{"type":"session"'`. The
/// anchor ties the match to a session record at the start of a line, so that
/// `title` line 0 is skipped and a `"type":"session"` substring nested inside
/// an earlier record is not picked in its place. Emitting one line per
/// file keeps a conversation line (arbitrary text on later lines) from ever
/// colliding with the `===PI:`/`===END===` delimiters.
///
/// `grep -m1` is a GNU and BusyBox extension rather than strict POSIX; both the
/// Debian and Alpine container bases support it, so it is safe for the images
/// pi-family agents run in.
const PI_CONTAINER_LIST_SCRIPT: &str = r#"SESS_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}/sessions"
[ -d "$SESS_DIR" ] || exit 0
for d in "$SESS_DIR"/*/; do
for f in "$d"*.jsonl; do
[ -f "$f" ] || continue
ts=$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f" 2>/dev/null || echo 0)
printf '===PI:%s===\n' "$ts"
head -n 8 "$f" | grep -m1 '^{"type":"session"'
printf '\n===END===\n'
done
done
"#;
/// Capture a Pi session ID from inside a Docker container.
///
/// Mirrors `capture_pi_session_id` but reads `.jsonl` headers via
/// `docker exec sh` since pi-in-container writes to the container's
/// `~/.pi/agent/sessions/`. Matches against `container_cwd` (the path
/// pi-in-container records), not the host project path.
pub(crate) fn try_capture_pi_session_id_in_container(
container_name: &str,
container_cwd: &str,
exclusion: &HashSet<String>,
) -> Result<String> {
let mut cmd = std::process::Command::new("docker");
cmd.args(["exec", container_name, "sh", "-c", PI_CONTAINER_LIST_SCRIPT]);
let stdout_bytes = run_with_timeout(
cmd,
Duration::from_secs(PI_COMMAND_TIMEOUT_SECS),
"docker exec sh (pi session scan)",
)?;
select_pi_session_in_container(&stdout_bytes, container_cwd, exclusion)
}
/// Parse the delimited stream emitted by `PI_CONTAINER_LIST_SCRIPT` and pick
/// the most recent session whose recorded CWD matches `container_cwd`.
fn select_pi_session_in_container(
stdout_bytes: &[u8],
container_cwd: &str,
exclusion: &HashSet<String>,
) -> Result<String> {
let text = String::from_utf8_lossy(stdout_bytes);
let mut candidates: Vec<(String, Option<String>, u64)> = Vec::new();
for chunk in text.split("===PI:").skip(1) {
let (ts_str, rest) = match chunk.split_once("===\n") {
Some(p) => p,
None => continue,
};
let ts: u64 = ts_str.trim().parse().unwrap_or(0);
let json_part = match rest.split_once("\n===END===") {
Some((j, _)) => j,
None => rest,
};
let (id_opt, cwd) = match parse_pi_header_json(json_part.trim()) {
Some(p) => p,
None => continue,
};
let session_id = match id_opt {
Some(id) if !id.is_empty() && !exclusion.contains(&id) => id,
_ => continue,
};
candidates.push((session_id, cwd, ts));
}
if candidates.is_empty() {
anyhow::bail!("No Pi sessions found in container");
}
candidates.sort_by_key(|c| std::cmp::Reverse(c.2));
let project_match = candidates
.iter()
.find(|(_, cwd, _)| cwd.as_deref() == Some(container_cwd));
project_match
.map(|(id, _, _)| id.clone())
.ok_or_else(|| anyhow::anyhow!("No Pi session matching container CWD"))
}
/// Polling closure for sandboxed (Docker) Pi session tracking.
pub(crate) fn pi_poll_fn_sandboxed(
container_name: String,
container_cwd: String,
instance_id: String,
extra_excludes: HashSet<String>,
) -> impl Fn() -> Option<String> + Send + 'static {
move || {
let exclusion = compose_exclusion(&instance_id, &extra_excludes);
try_capture_pi_session_id_in_container(&container_name, &container_cwd, &exclusion)
.map_err(|e| tracing::debug!(target: "session.capture", "Pi container poll capture failed: {}", e))
.ok()
.and_then(validated_session_id)
}
}
pub(crate) fn is_valid_session_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 256
@@ -1060,7 +806,7 @@ fn build_exclusion_set(current_instance_id: &str) -> HashSet<String> {
.lines()
.filter(|name| {
name.starts_with(crate::tmux::SESSION_PREFIX)
&& !name.starts_with(crate::tmux::TOOL_PREFIX)
&& !name.starts_with(crate::tmux::LEGACY_TOOL_PREFIX)
})
.collect();
@@ -1639,39 +1385,6 @@ fn preassign_opencode_session_id_impl(project_path: &str) -> Result<String> {
Ok(id)
}
/// Capture an OpenCode session ID from inside a Docker container.
///
/// Mirrors `try_capture_opencode_session_id` but runs `opencode session list`
/// via `docker exec -w <cwd>`. Matching is done against `container_cwd` (the
/// path opencode-in-container records as its working directory), not the host
/// project path.
pub(crate) fn try_capture_opencode_session_id_in_container(
container_name: &str,
container_cwd: &str,
exclusion: &HashSet<String>,
launch_time_ms: Option<f64>,
) -> Result<String> {
let mut cmd = std::process::Command::new("docker");
cmd.args([
"exec",
"-w",
container_cwd,
container_name,
"opencode",
"session",
"list",
"--format",
"json",
]);
let stdout_bytes = run_with_timeout(
cmd,
Duration::from_secs(OPENCODE_COMMAND_TIMEOUT_SECS),
"opencode session list (container)",
)?;
select_opencode_session(&stdout_bytes, container_cwd, exclusion, launch_time_ms)
}
/// Polling closure for OpenCode session tracking.
pub(crate) fn opencode_poll_fn(
project_path: String,
@@ -1688,47 +1401,8 @@ pub(crate) fn opencode_poll_fn(
}
}
/// Polling closure for sandboxed (Docker) OpenCode session tracking.
pub(crate) fn opencode_poll_fn_sandboxed(
container_name: String,
container_cwd: String,
instance_id: String,
launch_time_ms: f64,
extra_excludes: HashSet<String>,
) -> impl Fn() -> Option<String> + Send + 'static {
move || {
let exclusion = compose_exclusion(&instance_id, &extra_excludes);
try_capture_opencode_session_id_in_container(
&container_name,
&container_cwd,
&exclusion,
Some(launch_time_ms),
)
.map_err(|e| tracing::debug!(target: "session.capture", "OpenCode container poll capture failed: {}", e))
.ok()
.and_then(validated_session_id)
}
}
// ─── Codex CLI session capture ────────────────────────────────────────────────
const CODEX_COMMAND_TIMEOUT_SECS: u64 = 5;
/// Shell snippet executed via `docker exec` to enumerate Codex `.jsonl` session
/// files inside the container. Each file is emitted as a
/// `===CODEX:<unix-mtime>:<basename>===` header followed by the first line of the
/// file and a `===END===` trailer.
const CODEX_CONTAINER_LIST_SCRIPT: &str = r#"SESS_DIR="${CODEX_HOME:-$HOME/.codex}/sessions"
[ -d "$SESS_DIR" ] || exit 0
find "$SESS_DIR" -name '*.jsonl' -type f | while read -r f; do
ts=$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f" 2>/dev/null || echo 0)
bn=$(basename "$f")
printf '===CODEX:%s:%s===\n' "$ts" "$bn"
head -n 1 "$f"
printf '\n===END===\n'
done
"#;
/// Capture session ID from Codex filesystem.
///
/// Walks the Codex sessions directory (including date-partitioned `YYYY/MM/DD/` subdirectories)
@@ -1853,82 +1527,6 @@ pub(crate) fn collect_codex_sessions(
Ok(())
}
/// Capture a Codex session ID from inside a Docker container.
///
/// Mirrors `capture_codex_session_id` but reads `.jsonl` headers via
/// `docker exec sh` since codex-in-container writes to the container's
/// `~/.codex/sessions/`. Matches against `container_cwd` (the path
/// codex-in-container records), not the host project path.
pub(crate) fn try_capture_codex_session_id_in_container(
container_name: &str,
container_cwd: &str,
exclusion: &HashSet<String>,
) -> Result<String> {
let mut cmd = std::process::Command::new("docker");
cmd.args([
"exec",
container_name,
"sh",
"-c",
CODEX_CONTAINER_LIST_SCRIPT,
]);
let stdout_bytes = run_with_timeout(
cmd,
Duration::from_secs(CODEX_COMMAND_TIMEOUT_SECS),
"docker exec sh (codex session scan)",
)?;
select_codex_session_in_container(&stdout_bytes, container_cwd, exclusion)
}
/// Parse the delimited stream emitted by `CODEX_CONTAINER_LIST_SCRIPT` and pick
/// the most recent session whose recorded CWD matches `container_cwd`.
fn select_codex_session_in_container(
stdout_bytes: &[u8],
container_cwd: &str,
exclusion: &HashSet<String>,
) -> Result<String> {
let text = String::from_utf8_lossy(stdout_bytes);
let mut candidates: Vec<(String, String, u64)> = Vec::new();
for chunk in text.split("===CODEX:").skip(1) {
let (header, rest) = match chunk.split_once("===\n") {
Some(p) => p,
None => continue,
};
let (ts_str, basename) = match header.split_once(':') {
Some(p) => p,
None => continue,
};
let ts: u64 = ts_str.trim().parse().unwrap_or(0);
let uuid = match extract_codex_uuid_from_filename(Path::new(basename.trim())) {
Some(u) if !exclusion.contains(&u) => u,
_ => continue,
};
let json_part = match rest.split_once("\n===END===") {
Some((j, _)) => j,
None => rest,
};
let cwd = match parse_codex_cwd_from_json(json_part.trim(), &uuid) {
Some(cwd) => cwd,
None => continue,
};
candidates.push((uuid, cwd, ts));
}
if candidates.is_empty() {
anyhow::bail!("No Codex sessions found in container");
}
candidates.sort_by_key(|c| std::cmp::Reverse(c.2));
let project_match = candidates.iter().find(|(_, cwd, _)| cwd == container_cwd);
project_match
.map(|(id, _, _)| id.clone())
.ok_or_else(|| anyhow::anyhow!("No Codex session matching container CWD"))
}
/// Polling closure for Codex CLI session tracking.
pub(crate) fn codex_poll_fn(
project_path: String,
@@ -1946,22 +1544,6 @@ pub(crate) fn codex_poll_fn(
}
}
/// Polling closure for sandboxed (Docker) Codex session tracking.
pub(crate) fn codex_poll_fn_sandboxed(
container_name: String,
container_cwd: String,
instance_id: String,
extra_excludes: HashSet<String>,
) -> impl Fn() -> Option<String> + Send + 'static {
move || {
let exclusion = compose_exclusion(&instance_id, &extra_excludes);
try_capture_codex_session_id_in_container(&container_name, &container_cwd, &exclusion)
.map_err(|e| tracing::debug!(target: "session.capture", "Codex container poll capture failed: {}", e))
.ok()
.and_then(validated_session_id)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2418,99 +2000,6 @@ mod tests {
}
}
#[test]
fn test_capture_claude_session_in_container_returns_error_for_missing_container() {
let result = capture_claude_session_id_in_container(
"aoe-test-nonexistent-container-xyz",
"/workspace/test",
&HashSet::new(),
None,
);
assert!(result.is_err());
}
/// Runs the production snippet under `sh` against a real directory, so the
/// guard is exercised without Docker. `CLAUDE_CONFIG_DIR` goes to the child
/// only, so this needs no `EnvGuard` and no serial key.
#[cfg(unix)]
#[test]
fn test_container_list_snippet_lists_only_regular_files() {
let sid = "11111111-1111-4111-8111-111111111111";
// `listed` is what the snippet should emit for each shape. The glob
// does match a directory, but `ls -tL` on one lists its contents
// rather than itself, so that entry never reaches `[ -f ]`; the row
// pins the listing step, not the guard.
let cases = [
("regular", true),
("symlink-to-transcript", true),
("directory", false),
("dangling-link", false),
("symlink-cycle", false),
("fifo", false),
];
for (kind, listed) in cases {
let home = tempfile::tempdir().unwrap();
let project_path = format!("/tmp/container-scan-probe-{kind}");
let dir = home
.path()
.join("projects")
.join(encode_claude_project_path(&project_path));
std::fs::create_dir_all(&dir).unwrap();
let entry = dir.join(format!("{sid}.jsonl"));
match kind {
"regular" => std::fs::write(&entry, "{}\n").unwrap(),
"symlink-to-transcript" => {
let target = dir.join("target.data");
std::fs::write(&target, "{}\n").unwrap();
std::os::unix::fs::symlink(&target, &entry).unwrap();
// Age the link past the five-minute gate while its target
// stays fresh. Without this the row passes on a link too
// young to distinguish lstat from stat, which is the case
// that actually breaks. BSD `touch` rejects this date
// form, so skip the row where it is unavailable rather
// than let it pass without testing anything.
let aged = std::process::Command::new("touch")
.args(["-h", "-d", "10 minutes ago"])
.arg(&entry)
.status()
.is_ok_and(|s| s.success());
if !aged {
continue;
}
}
"directory" => std::fs::create_dir(&entry).unwrap(),
"dangling-link" => {
std::os::unix::fs::symlink(dir.join("gone.jsonl"), &entry).unwrap()
}
"symlink-cycle" => std::os::unix::fs::symlink(&entry, &entry).unwrap(),
"fifo" => {
// Skipped rather than failed where `mkfifo` is unavailable;
// the other rows still gate the guard.
match std::process::Command::new("mkfifo").arg(&entry).status() {
Ok(s) if s.success() => {}
_ => continue,
}
}
_ => continue,
}
let out = std::process::Command::new("sh")
.arg("-c")
.arg(claude_container_list_snippet(&encode_claude_project_path(
&project_path,
)))
.env("CLAUDE_CONFIG_DIR", home.path())
.output()
.expect("snippet invocation failed");
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(
stdout.lines().any(|l| l.trim() == sid),
listed,
"{kind}: unexpected listing, stdout {stdout:?}",
);
}
}
#[test]
fn test_encode_pi_project_path() {
// Every path separator (both flavors) and `:` collapses to `-`, and the
@@ -2655,45 +2144,6 @@ mod tests {
}
}
/// Real e2e: run the same shell script we ship to `docker exec` against a
/// Pi session dir on disk, and feed the stdout into the parser to confirm
/// it picks up the live UUID. Set `AOE_PI_E2E_DIR=/path/to/.pi/agent` and
/// `AOE_PI_E2E_PROJECT=/abs/project/path` to enable; otherwise skipped.
/// Validates the production `PI_CONTAINER_LIST_SCRIPT` against real Pi
/// output without needing Docker.
#[test]
#[serial]
fn test_select_pi_session_in_container_against_real_script_output() {
let agent_dir = match std::env::var("AOE_PI_E2E_DIR") {
Ok(v) => v,
Err(_) => return,
};
let project_path = match std::env::var("AOE_PI_E2E_PROJECT") {
Ok(v) => v,
Err(_) => return,
};
let output = std::process::Command::new("sh")
.arg("-c")
.arg(PI_CONTAINER_LIST_SCRIPT)
.env("PI_CODING_AGENT_DIR", &agent_dir)
.output()
.expect("script invocation failed");
assert!(
output.status.success(),
"script exited non-zero: {:?}",
output.status
);
let id = select_pi_session_in_container(&output.stdout, &project_path, &HashSet::new())
.expect("parser failed on real Pi output");
assert!(
Uuid::parse_str(&id).is_ok(),
"captured id {id:?} is not a UUID"
);
eprintln!("captured pi session id via container script: {id}");
}
/// Real e2e: when run against a session dir produced by an actual `pi`
/// binary, capture must return an ID that `pi --session <id>` accepts.
/// Set `AOE_PI_E2E_DIR=/path/to/.pi/agent` and
@@ -3033,70 +2483,6 @@ mod tests {
);
}
#[test]
fn test_select_pi_session_in_container_picks_most_recent_match() {
let stdout = b"\
===PI:1700000000===
{\"type\":\"session\",\"id\":\"older-match\",\"cwd\":\"/workspace\"}
===END===
===PI:1700001000===
{\"type\":\"session\",\"id\":\"newer-match\",\"cwd\":\"/workspace\"}
===END===
===PI:1700002000===
{\"type\":\"session\",\"id\":\"other-project\",\"cwd\":\"/elsewhere\"}
===END===
";
let result = select_pi_session_in_container(stdout, "/workspace", &HashSet::new()).unwrap();
assert_eq!(result, "newer-match");
}
#[test]
fn test_select_pi_session_in_container_respects_exclusion() {
let stdout = b"\
===PI:1700001000===
{\"type\":\"session\",\"id\":\"already-claimed\",\"cwd\":\"/workspace\"}
===END===
===PI:1700000500===
{\"type\":\"session\",\"id\":\"available\",\"cwd\":\"/workspace\"}
===END===
";
let mut exclusion = HashSet::new();
exclusion.insert("already-claimed".to_string());
let result = select_pi_session_in_container(stdout, "/workspace", &exclusion).unwrap();
assert_eq!(result, "available");
}
#[test]
fn test_select_pi_session_in_container_no_match_returns_error() {
let stdout = b"\
===PI:1700000000===
{\"type\":\"session\",\"id\":\"foo\",\"cwd\":\"/somewhere/else\"}
===END===
";
let result = select_pi_session_in_container(stdout, "/workspace", &HashSet::new());
assert!(result.is_err());
}
#[test]
fn test_select_pi_session_in_container_empty_input() {
let result = select_pi_session_in_container(b"", "/workspace", &HashSet::new());
assert!(result.is_err());
}
#[test]
fn test_select_pi_session_in_container_skips_non_session_lines() {
let stdout = b"\
===PI:1700000000===
{\"type\":\"message\",\"id\":\"not-a-session\",\"cwd\":\"/workspace\"}
===END===
===PI:1700001000===
{\"type\":\"session\",\"id\":\"valid\",\"cwd\":\"/workspace\"}
===END===
";
let result = select_pi_session_in_container(stdout, "/workspace", &HashSet::new()).unwrap();
assert_eq!(result, "valid");
}
#[test]
fn test_opencode_directory_matching() {
let sessions_json = serde_json::json!([
@@ -3463,92 +2849,6 @@ mod tests {
assert!(result.is_err(), "Empty sessions dir should return error");
}
#[test]
fn test_select_codex_session_in_container_most_recent() {
let uuid_old = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
let uuid_new = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
let uuid_other = "cccccccc-cccc-cccc-cccc-cccccccccccc";
let stdout = format!(
"\
===CODEX:1700000000:rollout-2025-01-01T00-00-00-{uuid_old}.jsonl===
{{\"type\":\"session_meta\",\"payload\":{{\"cwd\":\"/workspace\"}}}}
===END===
===CODEX:1700001000:rollout-2025-01-02T00-00-00-{uuid_new}.jsonl===
{{\"type\":\"session_meta\",\"payload\":{{\"cwd\":\"/workspace\"}}}}
===END===
===CODEX:1700002000:rollout-2025-01-03T00-00-00-{uuid_other}.jsonl===
{{\"type\":\"session_meta\",\"payload\":{{\"cwd\":\"/elsewhere\"}}}}
===END===
"
);
let result =
select_codex_session_in_container(stdout.as_bytes(), "/workspace", &HashSet::new())
.unwrap();
assert_eq!(result, uuid_new);
}
#[test]
fn test_select_codex_session_in_container_exclusion() {
let uuid_claimed = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
let uuid_available = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
let stdout = format!(
"\
===CODEX:1700001000:rollout-2025-01-02T00-00-00-{uuid_claimed}.jsonl===
{{\"type\":\"session_meta\",\"payload\":{{\"cwd\":\"/workspace\"}}}}
===END===
===CODEX:1700000500:rollout-2025-01-01T00-00-00-{uuid_available}.jsonl===
{{\"type\":\"session_meta\",\"payload\":{{\"cwd\":\"/workspace\"}}}}
===END===
"
);
let mut exclusion = HashSet::new();
exclusion.insert(uuid_claimed.to_string());
let result =
select_codex_session_in_container(stdout.as_bytes(), "/workspace", &exclusion).unwrap();
assert_eq!(result, uuid_available);
}
#[test]
fn test_select_codex_session_in_container_ignores_newer_child_with_parent_session_id() {
let root_uuid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
let child_uuid = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
let stdout = format!(
"\
===CODEX:1700001000:rollout-2025-01-02T00-00-00-{root_uuid}.jsonl===
{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{root_uuid}\",\"session_id\":\"{root_uuid}\",\"cwd\":\"/workspace\"}}}}
===END===
===CODEX:1700002000:rollout-2025-01-02T00-01-00-{child_uuid}.jsonl===
{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{child_uuid}\",\"session_id\":\"{root_uuid}\",\"cwd\":\"/workspace\"}}}}
===END===
"
);
let result =
select_codex_session_in_container(stdout.as_bytes(), "/workspace", &HashSet::new())
.unwrap();
assert_eq!(result, root_uuid);
}
#[test]
fn test_select_codex_session_in_container_no_match() {
let uuid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
let stdout = format!(
"\
===CODEX:1700000000:rollout-2025-01-01T00-00-00-{uuid}.jsonl===
{{\"type\":\"session_meta\",\"payload\":{{\"cwd\":\"/somewhere/else\"}}}}
===END===
"
);
let result =
select_codex_session_in_container(stdout.as_bytes(), "/workspace", &HashSet::new());
assert!(result.is_err());
}
#[test]
fn test_select_codex_session_in_container_empty_input() {
let result = select_codex_session_in_container(b"", "/workspace", &HashSet::new());
assert!(result.is_err());
}
// ─── Gemini tests ─────────────────────────────────────────────────────────
// ─── Hermes tests ────────────────────────────────────────────────────────────
@@ -3843,77 +3143,6 @@ mod tests {
}
}
#[test]
fn test_select_claude_session_in_container_anchor_at_position_zero() {
let uuid_a = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
let uuid_b = "11111111-2222-3333-4444-555555555555";
let stdout = format!("{uuid_a}\n{uuid_b}\n");
let id =
select_claude_session_in_container(stdout.as_bytes(), &HashSet::new(), Some(uuid_a))
.unwrap();
assert_eq!(id, uuid_a);
}
#[test]
fn test_select_claude_session_in_container_active_newer_wins_over_anchor() {
let uuid_anchor = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
let uuid_newer = "11111111-2222-3333-4444-555555555555";
let stdout = format!("{uuid_newer}\n{uuid_anchor}\n");
let id = select_claude_session_in_container(
stdout.as_bytes(),
&HashSet::new(),
Some(uuid_anchor),
)
.unwrap();
assert_eq!(id, uuid_newer);
}
#[test]
fn test_select_claude_session_in_container_excluded_newest_falls_to_anchor() {
let uuid_anchor = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
let uuid_sibling = "11111111-2222-3333-4444-555555555555";
let stdout = format!("{uuid_sibling}\n{uuid_anchor}\n");
let exclusion: HashSet<String> = std::iter::once(uuid_sibling.to_string()).collect();
let id =
select_claude_session_in_container(stdout.as_bytes(), &exclusion, Some(uuid_anchor))
.unwrap();
assert_eq!(id, uuid_anchor);
}
#[test]
fn test_select_claude_session_in_container_no_candidates_errors() {
let result = select_claude_session_in_container(b"", &HashSet::new(), None);
assert!(result.is_err());
}
#[test]
fn test_select_claude_session_in_container_all_candidates_excluded_errors() {
let uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
let exclusion: HashSet<String> = std::iter::once(uuid.to_string()).collect();
let stdout = format!("{uuid}\n");
let result = select_claude_session_in_container(stdout.as_bytes(), &exclusion, None);
assert!(result.is_err());
}
#[test]
fn test_select_claude_session_in_container_no_anchor_picks_first_unexcluded() {
let uuid_a = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
let uuid_b = "11111111-2222-3333-4444-555555555555";
let exclusion: HashSet<String> = std::iter::once(uuid_a.to_string()).collect();
let stdout = format!("{uuid_a}\n{uuid_b}\n");
let id = select_claude_session_in_container(stdout.as_bytes(), &exclusion, None).unwrap();
assert_eq!(id, uuid_b);
}
#[test]
fn test_select_claude_session_in_container_ignores_non_uuid_lines() {
let uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
let stdout = format!("\n \nnot-a-uuid\n{uuid}\nstill-not-a-uuid\n");
let id =
select_claude_session_in_container(stdout.as_bytes(), &HashSet::new(), None).unwrap();
assert_eq!(id, uuid);
}
#[test]
#[serial]
fn test_claude_poll_fn_reads_hook_sidecar_first() {
+2 -712
View File
@@ -24,15 +24,9 @@ pub struct Config {
#[serde(default)]
pub theme: ThemeConfig,
#[serde(default)]
pub updates: UpdatesConfig,
#[serde(default)]
pub worktree: WorktreeConfig,
#[serde(default)]
pub sandbox: SandboxConfig,
#[serde(default)]
pub tmux: TmuxConfig,
@@ -80,14 +74,6 @@ pub struct Config {
deserialize_with = "super::serde_helpers::string_or_vec"
)]
pub environment: Vec<String>,
/// User-defined tool sessions: name -> config.
/// Tools are launched in the selected session's working directory and
/// persist as independent tmux sessions until the parent agent session
/// is deleted. Access via hotkey, the tool picker (`;`), or command
/// palette (Ctrl+K).
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub tools: HashMap<String, ToolSessionConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
@@ -129,27 +115,6 @@ pub struct StatusRule {
pub regex: Option<String>,
}
fn is_false(value: &bool) -> bool {
!*value
}
/// Configuration for a user-defined tool session (lazygit, yazi, tig, etc.)
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolSessionConfig {
/// Shell command to run (e.g. "lazygit", "yazi", "tig --all").
/// The string is passed to the shell, so pipes and `&&` work.
#[serde(default)]
pub command: String,
/// Optional hotkey binding in `Alt+<letter>` format (e.g. "Alt+g", "Alt+f").
/// Only Alt+ single-character bindings are supported.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hotkey: Option<String>,
/// Run fire-and-forget in the selected session's working directory instead
/// of opening a persistent tmux tool session.
#[serde(default, skip_serializing_if = "is_false")]
pub background: bool,
}
/// Persistent logging configuration. Drives the default tracing
/// filter when no `AOE_LOG_LEVEL` env var is set, and is the
/// source of truth the settings UI writes to.
@@ -654,13 +619,6 @@ pub struct AppStateConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dismissed_update_version: Option<String>,
/// Registry digest of the sandbox image the user dismissed the
/// "image update available" banner for. The banner stays hidden while the
/// registry still resolves to this digest and returns automatically once a
/// newer image is published (the digest no longer matches).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dismissed_image_digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub home_list_width: Option<u16>,
@@ -688,14 +646,6 @@ pub struct AppStateConfig {
#[serde(default)]
pub has_acknowledged_agent_hooks: bool,
/// True once the user has acknowledged that glob `volume_ignores` entries
/// (e.g. `**/bin`) are expanded against the workspace at session-create time,
/// a point-in-time snapshot that won't shadow directories created later by an
/// in-container build (#2045). Gates the one-time confirm dialog shown before a
/// sandbox session whose resolved config contains a glob ignore.
#[serde(default)]
pub has_acknowledged_volume_ignores_globs: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sort_order: Option<SortOrder>,
@@ -1069,13 +1019,12 @@ pub struct SessionConfig {
pub restart_wake_message: String,
/// What to show next to each session title: Branch (default), Auto
/// (profile in all-profiles view), None, Profile (always), or Sandbox
/// (sb on sandboxed rows).
/// (profile in all-profiles view), None, or Profile (always).
#[serde(default)]
#[setting(
label = "Row Tag",
widget = "select",
options = "none:None,auto:Auto,profile:Profile,sandbox:Sandbox,branch:Branch"
options = "none:None,auto:Auto,profile:Profile,branch:Branch"
)]
pub row_tag: RowTagMode,
@@ -1374,8 +1323,6 @@ pub enum RowTagMode {
Auto,
/// Always render the profile short code (`fb` for `forit-backup`).
Profile,
/// Render `sb` on sandboxed sessions, nothing on host sessions.
Sandbox,
/// Render the worktree or workspace branch name, compacted into the row tag.
#[default]
Branch,
@@ -1660,49 +1607,6 @@ fn default_idle_decay_minutes() -> u64 {
0
}
/// Controls how the TUI and CLI surface update availability. See #1140.
///
/// `Auto` quietly installs new releases in the background on next launch
/// after detection (mid-session restart is intentionally out of scope, the
/// new binary is picked up next time `aoe` starts). `Notify` is the
/// default: shows the TUI banner and the CLI eprintln nag. `Off`
/// suppresses every check, banner, and fetch.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum UpdateCheckMode {
/// Kept so a config written before the aoe self-updater was removed still
/// parses; behaves identically to `Notify`.
Auto,
/// Run the check (default).
#[default]
Notify,
/// Skip every check and fetch.
Off,
}
impl UpdateCheckMode {
/// True when the runtime should run its network staleness checks (the
/// sandbox-image registry probe). Only `Off` short-circuits.
pub fn is_enabled(self) -> bool {
!matches!(self, UpdateCheckMode::Off)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, SettingsSection)]
#[setting_section(name = "updates", category = "Updates")]
pub struct UpdatesConfig {
/// Gates the background sandbox-image staleness check.
/// notify = run it (default). off = skip it. auto is a legacy alias for
/// notify, kept so existing configs still parse.
#[serde(default)]
#[setting(
label = "Update Check Mode",
widget = "select",
options = "auto:auto,notify:notify,off:off"
)]
pub update_check_mode: UpdateCheckMode,
}
fn default_true() -> bool {
true
}
@@ -1834,262 +1738,6 @@ fn default_workspace_template() -> String {
"../{branch}-workspace-{session-id}".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, SettingsSection)]
#[setting_section(name = "sandbox", category = "Sandbox")]
pub struct SandboxConfig {
/// Enable sandbox mode by default for new sessions.
#[serde(default)]
#[setting(
label = "Enabled by Default",
widget = "toggle",
web = "elevation:sandbox config affects host isolation"
)]
pub enabled_by_default: bool,
/// Container image to use for sandboxes.
#[serde(default = "default_sandbox_image")]
#[setting(
label = "Default Image",
widget = "text",
web = "elevation:sandbox config affects host isolation"
)]
pub default_image: String,
/// Additional volume mounts (host:container or host:container:ro).
#[serde(default, deserialize_with = "super::serde_helpers::string_or_vec")]
#[setting(
label = "Extra Volumes",
widget = "list",
validate = "volume_list",
web = "elevation:sandbox config affects host isolation",
advanced
)]
pub extra_volumes: Vec<String>,
/// Env vars injected into the container: KEY=value (literal, appears in
/// argv), KEY=$VAR (passthrough from host, hidden from argv), KEY=$$literal
/// (escape a leading $), or bare KEY (passthrough). For host (non-sandboxed)
/// sessions, see Session > Host Environment instead.
#[serde(
default = "default_sandbox_environment",
deserialize_with = "super::serde_helpers::string_or_vec"
)]
#[setting(
label = "Sandbox Environment",
widget = "list",
validate = "env_list",
web = "elevation:sandbox config affects host isolation",
advanced
)]
pub environment: Vec<String>,
/// Remove containers when sessions are deleted.
#[serde(default = "default_true")]
#[setting(
label = "Auto Cleanup",
widget = "toggle",
web = "elevation:sandbox config affects host isolation"
)]
pub auto_cleanup: bool,
/// CPU limit for containers (e.g. "4").
#[serde(default, skip_serializing_if = "Option::is_none")]
#[setting(
label = "CPU Limit",
widget = "optional_text",
web = "elevation:sandbox config affects host isolation",
advanced
)]
pub cpu_limit: Option<String>,
/// Memory limit for containers (e.g. "8g", "512m").
#[serde(default, skip_serializing_if = "Option::is_none")]
#[setting(
label = "Memory Limit",
widget = "optional_text",
validate = "memory_limit",
web = "elevation:sandbox config affects host isolation",
advanced
)]
pub memory_limit: Option<String>,
/// Expose container ports to host (e.g. 3000:3000).
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
deserialize_with = "super::serde_helpers::string_or_vec"
)]
#[setting(
label = "Port Mappings",
widget = "list",
validate = "port_mapping_list",
web = "elevation:sandbox config affects host isolation",
advanced
)]
pub port_mappings: Vec<String>,
/// Container network mode: unset or "bridge" for the default (full outbound
/// via the runtime's bridge), "none" for no network (isolates the agent but
/// also cuts off its own model API unless a proxy is routed in), or a named
/// network to attach a user-defined network with its own egress filtering.
/// "host" is rejected because sharing the host network namespace defeats
/// sandbox isolation.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[setting(
label = "Network",
widget = "optional_text",
validate = "network",
web = "elevation:sandbox config affects host isolation",
advanced
)]
pub network: Option<String>,
/// Default terminal for sandboxed sessions (toggle with 'c' key).
#[serde(default)]
#[setting(
label = "Default Terminal Mode",
widget = "select",
options = "host:Host,container:Container",
web = "elevation:sandbox config affects host isolation"
)]
pub default_terminal_mode: DefaultTerminalMode,
/// Directories to exclude from host mount (e.g. target, node_modules).
#[serde(default, deserialize_with = "super::serde_helpers::string_or_vec")]
#[setting(
label = "Volume Ignores",
widget = "list",
web = "elevation:sandbox config affects host isolation",
advanced
)]
pub volume_ignores: Vec<String>,
/// anonymous: default, works on Linux. named: use deterministic
/// Docker/Podman named volumes, required on macOS/VirtioFS to reliably
/// shadow bind-mount subdirectories.
#[serde(default)]
#[setting(
label = "Volume Ignores Strategy",
widget = "select",
options = "anonymous:anonymous,named:named",
web = "elevation:sandbox config affects host isolation",
advanced
)]
pub volume_ignores_strategy: VolumeIgnoresStrategy,
/// Mount ~/.ssh into sandbox containers (for git SSH access).
#[serde(default)]
#[setting(
label = "Mount SSH",
widget = "toggle",
web = "elevation:sandbox config affects host isolation"
)]
pub mount_ssh: bool,
/// Append the :z SELinux relabel flag to sandbox bind mounts (needed on
/// Fedora/RHEL; relabels host paths). Off by default; only emitted for
/// Docker/Podman.
#[serde(default)]
#[setting(
label = "SELinux Relabel",
widget = "toggle",
web = "elevation:sandbox config affects host isolation",
advanced
)]
pub selinux_relabel: bool,
/// Custom instruction text appended to the agent's system prompt in
/// sandboxed sessions (Claude, Codex only).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[setting(
label = "Custom Instruction",
widget = "optional_text",
web = "elevation:sandbox config affects host isolation",
advanced
)]
pub custom_instruction: Option<String>,
/// Container runtime for sandboxing.
#[serde(default)]
#[setting(
label = "Container Runtime",
widget = "select",
options = "docker:Docker,podman:Podman,apple_container:Apple Container",
web = "elevation:sandbox config affects host isolation"
)]
pub container_runtime: ContainerRuntimeName,
}
/// Container runtime options for sandboxing
#[derive(Serialize, Deserialize, Debug, Default, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ContainerRuntimeName {
AppleContainer,
#[default]
Docker,
Podman,
}
/// Volume mounting strategy for volume_ignores paths.
///
/// On macOS with Docker Desktop's VirtioFS, anonymous volumes don't always shadow
/// bind-mount subdirectories. Use `Named` to mount deterministic named Docker/Podman
/// volumes instead, which live in the Docker VM and bypass VirtioFS reliably.
#[derive(Serialize, Deserialize, Debug, Default, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum VolumeIgnoresStrategy {
/// Use anonymous volumes (default; works on Linux; may not shadow on macOS/VirtioFS)
#[default]
Anonymous,
/// Use deterministic named volumes (required on macOS/VirtioFS; explicitly cleaned up on session delete)
Named,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
enabled_by_default: false,
default_image: default_sandbox_image(),
extra_volumes: Vec::new(),
environment: default_sandbox_environment(),
auto_cleanup: true,
cpu_limit: None,
memory_limit: None,
port_mappings: Vec::new(),
network: None,
default_terminal_mode: DefaultTerminalMode::default(),
volume_ignores: Vec::new(),
volume_ignores_strategy: VolumeIgnoresStrategy::default(),
mount_ssh: false,
selinux_relabel: false,
custom_instruction: None,
container_runtime: ContainerRuntimeName::default(),
}
}
}
fn default_sandbox_image() -> String {
"ghcr.io/agent-of-empires/aoe-sandbox:latest".to_string()
}
fn default_sandbox_environment() -> Vec<String> {
crate::session::environment::DEFAULT_TERMINAL_ENV_VARS
.iter()
.map(|s| s.to_string())
.collect()
}
/// Default terminal mode for sandboxed sessions
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum DefaultTerminalMode {
/// Default to host terminal (shell on the host machine)
#[default]
Host,
/// Default to container terminal (shell inside the Docker container)
Container,
}
/// The three-way mode shared by every tmux option aoe manages on its own
/// sessions: always apply aoe's value, never apply it, or decide from the
/// user's own tmux config.
@@ -2860,10 +2508,6 @@ pub fn effective_profile(profile: &str) -> String {
}
}
pub fn get_update_settings() -> UpdatesConfig {
Config::load_or_warn().updates
}
/// Wrap a value so the launching shell passes it through literally.
///
/// Only quotes when the value contains something a shell would interpret, so
@@ -3281,39 +2925,6 @@ mod tests {
);
}
#[test]
#[serial_test::serial]
fn test_load_or_warn_returns_defaults_on_malformed_toml() {
let temp_home = tempfile::TempDir::new().unwrap();
std::env::set_var("HOME", temp_home.path());
#[cfg(any(target_os = "linux", target_os = "macos"))]
std::env::set_var("XDG_CONFIG_HOME", temp_home.path().join(".config"));
#[cfg(any(target_os = "linux", target_os = "macos"))]
let app_dir = temp_home
.path()
.join(".config")
.join(crate::session::APP_DIR_NAME_XDG);
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
let app_dir = temp_home.path().join(crate::session::APP_DIR_NAME_OTHER);
std::fs::create_dir_all(&app_dir).unwrap();
// Malformed: 'enabled_by_default' under [sandbox] expects a boolean.
std::fs::write(
app_dir.join("config.toml"),
"[sandbox]\nenabled_by_default = \"not-a-bool\"\n",
)
.unwrap();
let config = Config::load_or_warn();
// Defaults restored rather than propagated; the parse error is logged.
let defaults = Config::default();
assert_eq!(
config.sandbox.enabled_by_default,
defaults.sandbox.enabled_by_default,
);
}
/// A symlinked global `config.toml` must survive a save via
/// `update_config`: the link stays a link and its target receives the
/// new content, instead of the save replacing the symlink with a
@@ -3362,20 +2973,6 @@ mod tests {
);
}
// Tests for Config defaults
#[test]
fn test_config_default() {
let config = Config::default();
// An unset default_profile deserializes empty: "not explicitly
// chosen". The active profile is resolved at runtime, not baked in
// as a magic name here.
let deserialized: Config = toml::from_str("").unwrap();
assert_eq!(deserialized.default_profile, "");
assert!(!config.worktree.enabled);
assert!(!config.sandbox.enabled_by_default);
assert_eq!(config.updates.update_check_mode, UpdateCheckMode::Notify);
}
#[test]
fn test_config_deserialize_empty_toml() {
let config: Config = toml::from_str("").unwrap();
@@ -3426,56 +3023,6 @@ mod tests {
assert_eq!(theme.idle_decay_minutes, 0);
}
// Tests for UpdatesConfig
#[test]
fn test_updates_config_default() {
let updates = UpdatesConfig::default();
assert_eq!(updates.update_check_mode, UpdateCheckMode::Notify);
}
#[test]
fn test_updates_config_deserialize() {
let toml = r#"
update_check_mode = "off"
"#;
let updates: UpdatesConfig = toml::from_str(toml).unwrap();
assert_eq!(updates.update_check_mode, UpdateCheckMode::Off);
}
#[test]
fn test_updates_config_partial_deserialize() {
let toml = r#"update_check_mode = "auto""#;
let updates: UpdatesConfig = toml::from_str(toml).unwrap();
assert_eq!(updates.update_check_mode, UpdateCheckMode::Auto);
}
#[test]
fn test_update_check_mode_helpers() {
assert!(UpdateCheckMode::Notify.is_enabled());
assert!(UpdateCheckMode::Auto.is_enabled());
assert!(!UpdateCheckMode::Off.is_enabled());
}
/// Regression: earlier schemas had `check_enabled`, `auto_update`,
/// `check_interval_hours`, `notify_in_cli`, and
/// `web_poll_interval_minutes` on UpdatesConfig. All are gone now;
/// the on-disk migration runs at startup, but configs read between
/// upgrade and migration must still deserialize cleanly with the
/// unknown fields silently dropped by serde.
#[test]
fn test_legacy_updates_fields_are_ignored() {
let old_toml = r#"
check_enabled = false
auto_update = true
check_interval_hours = 12
notify_in_cli = true
web_poll_interval_minutes = 30
"#;
let updates: UpdatesConfig =
toml::from_str(old_toml).expect("legacy fields should not error");
assert_eq!(updates.update_check_mode, UpdateCheckMode::Notify);
}
// Tests for WorktreeConfig
#[test]
fn test_worktree_config_default() {
@@ -3515,95 +3062,6 @@ mod tests {
assert!(wt.init_submodules);
}
// Tests for SandboxConfig
#[test]
fn test_sandbox_config_default() {
let sb = SandboxConfig::default();
assert!(!sb.enabled_by_default);
assert!(sb.auto_cleanup);
assert!(sb.extra_volumes.is_empty());
assert!(sb.environment.contains(&"TERM".to_string()));
assert!(sb.environment.contains(&"COLORTERM".to_string()));
assert!(sb.cpu_limit.is_none());
assert!(sb.memory_limit.is_none());
assert!(sb.volume_ignores.is_empty());
assert!(sb.network.is_none());
}
#[test]
fn test_sandbox_config_deserialize() {
let toml = r#"
enabled_by_default = true
default_image = "custom:latest"
extra_volumes = ["/data:/data"]
environment = ["MY_VAR"]
auto_cleanup = false
cpu_limit = "2"
memory_limit = "4g"
port_mappings = ["3000:3000", "5432:5432"]
network = "none"
"#;
let sb: SandboxConfig = toml::from_str(toml).unwrap();
assert!(sb.enabled_by_default);
assert_eq!(sb.default_image, "custom:latest");
assert_eq!(sb.extra_volumes, vec!["/data:/data"]);
assert_eq!(sb.environment, vec!["MY_VAR"]);
assert!(!sb.auto_cleanup);
assert_eq!(sb.cpu_limit, Some("2".to_string()));
assert_eq!(sb.memory_limit, Some("4g".to_string()));
assert_eq!(
sb.port_mappings,
vec!["3000:3000".to_string(), "5432:5432".to_string()]
);
assert_eq!(sb.network, Some("none".to_string()));
}
#[test]
fn test_sandbox_config_volume_ignores_deserialize() {
let toml = r#"
volume_ignores = ["target", ".venv", "node_modules"]
"#;
let sb: SandboxConfig = toml::from_str(toml).unwrap();
assert_eq!(sb.volume_ignores, vec!["target", ".venv", "node_modules"]);
}
#[test]
fn test_sandbox_config_volume_ignores_defaults_empty() {
let toml = r#"enabled_by_default = false"#;
let sb: SandboxConfig = toml::from_str(toml).unwrap();
assert!(sb.volume_ignores.is_empty());
}
#[test]
fn test_sandbox_config_volume_ignores_roundtrip() {
let mut config = Config::default();
config.sandbox.volume_ignores = vec!["target".to_string(), "node_modules".to_string()];
let serialized = toml::to_string(&config).unwrap();
let deserialized: Config = toml::from_str(&serialized).unwrap();
assert_eq!(
deserialized.sandbox.volume_ignores,
vec!["target", "node_modules"]
);
}
#[test]
fn test_sandbox_config_string_shorthand() {
// Regression test: all Vec<String> sandbox fields accept a plain string
let toml = r#"
environment = "ANTHROPIC_API_KEY"
extra_volumes = "/data:/data:ro"
volume_ignores = "node_modules"
port_mappings = "3000:3000"
"#;
let sb: SandboxConfig = toml::from_str(toml).unwrap();
assert_eq!(sb.environment, vec!["ANTHROPIC_API_KEY"]);
assert_eq!(sb.extra_volumes, vec!["/data:/data:ro"]);
assert_eq!(sb.volume_ignores, vec!["node_modules"]);
assert_eq!(sb.port_mappings, vec!["3000:3000"]);
}
// Tests for AppStateConfig
#[test]
fn test_app_state_config_default() {
@@ -3639,81 +3097,6 @@ mod tests {
assert_eq!(reparsed.row_tag, RowTagMode::None);
}
// Full config serialization roundtrip
#[test]
fn test_config_serialization_roundtrip() {
let config = Config {
default_profile: "test".to_string(),
worktree: WorktreeConfig {
enabled: true,
..Default::default()
},
sandbox: SandboxConfig {
enabled_by_default: true,
..Default::default()
},
updates: UpdatesConfig {
update_check_mode: UpdateCheckMode::Auto,
},
..Default::default()
};
let serialized = toml::to_string(&config).unwrap();
let deserialized: Config = toml::from_str(&serialized).unwrap();
assert_eq!(config.default_profile, deserialized.default_profile);
assert_eq!(config.worktree.enabled, deserialized.worktree.enabled);
assert_eq!(
config.sandbox.enabled_by_default,
deserialized.sandbox.enabled_by_default
);
assert_eq!(
config.updates.update_check_mode,
deserialized.updates.update_check_mode
);
}
// Test nested sections in TOML
#[test]
fn test_config_nested_sections() {
let toml = r#"
default_profile = "work"
[theme]
name = "monokai"
[worktree]
enabled = true
path_template = "../wt/{branch}"
[sandbox]
enabled_by_default = true
[updates]
update_check_mode = "notify"
[app_state]
has_acknowledged_agent_hooks = true
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.default_profile, "work");
assert_eq!(config.theme.name, "monokai");
assert!(config.worktree.enabled);
assert_eq!(config.worktree.path_template, "../wt/{branch}");
assert!(config.sandbox.enabled_by_default);
assert_eq!(config.updates.update_check_mode, UpdateCheckMode::Notify);
assert!(config.app_state.has_acknowledged_agent_hooks);
}
// Test get_update_settings helper
#[test]
fn test_get_update_settings_returns_defaults_when_no_config() {
// This test doesn't access the filesystem, so it should return defaults
let settings = UpdatesConfig::default();
assert_eq!(settings.update_check_mode, UpdateCheckMode::Notify);
}
// Tests for TmuxConfig
/// The three `[tmux]` mode fields share one `TmuxSettingMode`, so this is
@@ -4082,49 +3465,6 @@ mod tests {
assert!(session.confirm_delete, "confirm_delete (#3364)");
}
#[test]
fn test_tool_background_defaults_false_when_absent() {
let toml = r#"
[tools.github]
command = "gh repo view --web"
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert!(!cfg.tools["github"].background);
}
#[test]
fn test_tool_background_roundtrips_when_enabled() {
let toml = r#"
[tools.github]
command = "gh repo view --web"
hotkey = "Alt+o"
background = true
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert!(cfg.tools["github"].background);
let serialized = toml::to_string_pretty(&cfg).unwrap();
assert!(serialized.contains("background = true"));
let reparsed: Config = toml::from_str(&serialized).unwrap();
assert!(reparsed.tools["github"].background);
}
#[test]
fn test_tool_background_false_is_omitted() {
let mut cfg = Config::default();
cfg.tools.insert(
"lazygit".to_string(),
ToolSessionConfig {
command: "lazygit".to_string(),
hotkey: None,
background: false,
},
);
let serialized = toml::to_string_pretty(&cfg).unwrap();
assert!(!serialized.contains("background"));
}
#[test]
fn test_resolve_tool_command_prefers_command_override() {
let mut config = SessionConfig::default();
@@ -4231,19 +3571,6 @@ mod tests {
assert!(config.session.agent_acp_cmd.is_empty());
}
#[test]
fn test_container_runtime_podman_round_trip() {
// Users on Linux configure podman via `container_runtime = "podman"`
// in config.toml; if the snake_case rename ever drifts, their config
// would silently fall back to the docker default.
let toml_str = r#"container_runtime = "podman""#;
let parsed: SandboxConfig = toml::from_str(toml_str).unwrap();
assert_eq!(parsed.container_runtime, ContainerRuntimeName::Podman);
let serialized = toml::to_string(&parsed).unwrap();
assert!(serialized.contains(r#"container_runtime = "podman""#));
}
#[test]
fn logging_config_old_shape_populates_new_defaults() {
// Existing user configs predate output/file_path/rotation; their
@@ -4290,43 +3617,6 @@ keep_count = 10
assert_eq!(reparsed.rotation, RotationKind::Never);
}
#[test]
fn test_volume_ignores_strategy_defaults_to_anonymous() {
let config: SandboxConfig = toml::from_str("").unwrap();
assert_eq!(
config.volume_ignores_strategy,
VolumeIgnoresStrategy::Anonymous
);
}
#[test]
fn test_volume_ignores_strategy_named_roundtrip() {
let toml_str = r#"
volume_ignores = ["node_modules"]
volume_ignores_strategy = "named"
"#;
let config: SandboxConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.volume_ignores_strategy, VolumeIgnoresStrategy::Named);
assert_eq!(config.volume_ignores, vec!["node_modules"]);
let serialized = toml::to_string(&config).unwrap();
let reparsed: SandboxConfig = toml::from_str(&serialized).unwrap();
assert_eq!(
reparsed.volume_ignores_strategy,
VolumeIgnoresStrategy::Named
);
}
#[test]
fn test_volume_ignores_strategy_anonymous_roundtrip() {
let toml_str = r#"volume_ignores_strategy = "anonymous""#;
let config: SandboxConfig = toml::from_str(toml_str).unwrap();
assert_eq!(
config.volume_ignores_strategy,
VolumeIgnoresStrategy::Anonymous
);
}
// Tests for the config.toml / state.toml split and update_config /
// update_app_state (#2306-adjacent: long-running-process clobber fix).
File diff suppressed because it is too large Load Diff
+17 -331
View File
@@ -5,7 +5,6 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::Utc;
use crate::containers::DockerContainer;
use crate::git::cleanup::remove_managed_worktree;
use crate::git::GitWorktree;
use crate::session::repo_config;
@@ -17,7 +16,6 @@ pub struct DeletionRequest {
pub instance: Instance,
pub delete_worktree: bool,
pub delete_branch: bool,
pub delete_sandbox: bool,
pub force_delete: bool,
/// When `true`, on_destroy hooks run detached from the controlling
/// terminal (TUI/web). When `false`, hooks inherit stdin/stdout so
@@ -358,7 +356,6 @@ impl PurgeTransaction {
instance: self.request.instance.clone(),
delete_worktree: self.request.delete_worktree,
delete_branch: self.request.delete_branch,
delete_sandbox: self.request.delete_sandbox,
force_delete: self.request.force_delete,
detach_hooks: self.request.detach_hooks,
keep_scratch: self.request.keep_scratch,
@@ -593,38 +590,14 @@ fn is_protected_default_branch(main_repo: &Path, branch: &str) -> bool {
#[cfg(test)]
pub fn perform_deletion(request: &DeletionRequest) -> DeletionResult {
run_on_destroy_hooks(&request.instance, request.detach_hooks);
perform_deletion_with(request, |session_id| {
DockerContainer::from_session_id(session_id).teardown(session_id)
})
perform_deletion_core(request, false)
}
fn perform_deletion_teardown_lifecycle_locked(request: &DeletionRequest) -> DeletionResult {
perform_deletion_core(request, true, |session_id| {
DockerContainer::from_session_id(session_id).teardown(session_id)
})
perform_deletion_core(request, true)
}
/// Core deletion routine, parameterized over how the sandbox container is torn
/// down so the container-removal contract can be exercised without a live
/// runtime.
///
/// NOTE: when the session is sandboxed and `delete_sandbox` is set, `teardown`
/// must be invoked unconditionally; it must not be gated behind a separate
/// existence probe, whose transient failure would skip removal and orphan a
/// live container.
#[cfg(test)]
fn perform_deletion_with(
request: &DeletionRequest,
teardown: impl FnOnce(&str) -> crate::containers::Teardown,
) -> DeletionResult {
perform_deletion_core(request, false, teardown)
}
fn perform_deletion_core(
request: &DeletionRequest,
lifecycle_locked: bool,
teardown: impl FnOnce(&str) -> crate::containers::Teardown,
) -> DeletionResult {
fn perform_deletion_core(request: &DeletionRequest, lifecycle_locked: bool) -> DeletionResult {
let mut errors = Vec::new();
let mut messages = Vec::new();
@@ -633,7 +606,6 @@ fn perform_deletion_core(
title = %request.instance.title,
delete_worktree = request.delete_worktree,
delete_branch = request.delete_branch,
delete_sandbox = request.delete_sandbox,
force_delete = request.force_delete,
worktree_branch = request.instance.worktree_info.as_ref().map(|w| w.branch.as_str()).unwrap_or("<none>"),
worktree_managed = request.instance.worktree_info.as_ref().map(|w| w.managed_by_aoe).unwrap_or(false),
@@ -660,21 +632,10 @@ fn perform_deletion_core(
request.instance.kill_all_tmux_sessions();
}
let is_sandboxed = request
.instance
.sandbox_info
.as_ref()
.is_some_and(|s| s.enabled);
// Host-side dirty check. The in-container preclean below destroys
// worktree contents unconditionally, which would silently violate
// the `force_delete=false` safety contract for users with untracked
// or modified files. Walk every managed worktree we'd touch and
// collect the dirty ones; preclean is skipped if anything is dirty
// (the `find -delete` runs at the workspace root and can't easily
// skip subpaths), and host-side worktree removal is skipped per
// path that's dirty. Container, branch, and hook stages still run
// per the user's flags.
// Host-side dirty check. Walk every managed worktree we'd touch and
// collect the dirty ones; host-side worktree removal is skipped per
// path that's dirty. Branch and hook stages still run per the user's
// flags.
// Every repo the session works in: they all live in
// `workspace_info.repos`, so one loop per stage covers them.
//
@@ -693,37 +654,12 @@ fn perform_deletion_core(
let preserved_worktree_paths =
stage_collect_preserved_worktrees(request, repos, &mut errors, &mut messages);
// Any preserved worktree, dirty or default-branch, blocks the in-container
// preclean (a recursive `find . -delete` that would reach through and
// destroy the contents we just decided to keep) and the host workspace-dir
// removal alike: with a worktree preserved under it the directory is not
// ours to remove, so we skip it rather than surface a spurious failure.
// Any preserved worktree, dirty or default-branch, blocks the host
// workspace-dir removal: with a worktree preserved under it the directory
// is not ours to remove, so we skip it rather than surface a spurious
// failure.
let any_preserved = !preserved_worktree_paths.is_empty();
if request.delete_worktree && is_sandboxed && !any_preserved {
tracing::debug!(target: "session.delete", session_id = %request.session_id, stage = "sandbox_worktree_preclean", "perform_deletion: stage");
// Best-effort. The container's workdir is the session's main
// worktree (or, for workspace sessions, the workspace root that
// contains every per-repo worktree). `find . -delete` from
// there recursively wipes everything we care about under the
// bind mount. If this fails, the host-side removal below will
// surface a permission error and exit cleanly. We do NOT walk
// workspace_info.repos here: their container mount paths are
// computed relative to the common ancestor of all repos
// (see compute_workspace_volume_paths) and don't necessarily
// match `/workspace/{repo.name}`, and the workspace-root walk
// already covers their contents.
let _ = crate::git::cleanup::cleanup_sandbox_worktree(&request.instance);
}
// Stage 3: container removal. Releases the bind mount on the
// worktree so the host can finish cleanup without racing in-
// container processes.
if request.delete_sandbox && is_sandboxed {
tracing::debug!(target: "session.delete", session_id = %request.session_id, stage = "container_remove", "perform_deletion: stage");
deletion_messages_for(teardown(&request.instance.id), &mut messages, &mut errors);
}
stage_remove_worktrees_and_branches(
request,
repos,
@@ -910,7 +846,6 @@ fn stage_remove_worktrees_and_branches(
&main_repo,
&request.instance,
request.force_delete,
request.delete_sandbox,
) {
errors.extend(errs);
} else {
@@ -954,7 +889,6 @@ fn stage_remove_worktrees_and_branches(
&main_repo,
&request.instance,
request.force_delete,
request.delete_sandbox,
) {
Ok(()) => {
messages.push(format!("Workspace ({}) worktree removed", repo.name));
@@ -1191,25 +1125,6 @@ fn stage_cleanup_scratch(
}
}
/// Map a container [`Teardown`](crate::containers::Teardown) outcome onto a
/// deletion's user-facing messages and errors.
///
/// A `Failed` outcome is recorded as an error so the caller keeps the session
/// record rather than dropping it and orphaning a live container; `AlreadyGone`
/// is a silent no-op (there was nothing to remove).
fn deletion_messages_for(
outcome: crate::containers::Teardown,
messages: &mut Vec<String>,
errors: &mut Vec<String>,
) {
use crate::containers::Teardown;
match outcome {
Teardown::Removed => messages.push("Container removed".to_string()),
Teardown::AlreadyGone => {}
Teardown::Failed(e) => errors.push(format!("Container: {}", e)),
}
}
/// Run on_destroy hooks for an instance. Uses best-effort execution so all
/// hooks are attempted even if some fail. Failures are logged as warnings
/// and never prevent deletion.
@@ -1251,33 +1166,17 @@ fn run_on_destroy_hooks(instance: &Instance, detach: bool) {
tracing::info!(target: "session.delete", "Running on_destroy hooks for session {}", instance.id);
let is_sandboxed = instance.sandbox_info.as_ref().is_some_and(|s| s.enabled);
let hook_env = repo_config::lifecycle_env_vars(instance);
// The caller controls detachment: TUI/web pass detach=true to avoid
// corrupting the rendered UI (see issue #901); CLI passes detach=false
// so interactive prompts work.
let errors = if is_sandboxed {
if let Some(ref sandbox) = instance.sandbox_info {
let workdir = instance.container_workdir();
repo_config::execute_hooks_in_container_best_effort(
&resolved_on_destroy,
&sandbox.container_name,
&workdir,
detach,
&hook_env,
)
} else {
vec![]
}
} else {
repo_config::execute_hooks_best_effort(
&resolved_on_destroy,
project_path,
detach,
&hook_env,
)
};
let errors = repo_config::execute_hooks_best_effort(
&resolved_on_destroy,
project_path,
detach,
&hook_env,
);
if !errors.is_empty() {
tracing::warn!(target: "session.delete",
@@ -1304,7 +1203,6 @@ mod tests {
instance,
delete_worktree: false,
delete_branch: false,
delete_sandbox: false,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
@@ -1337,7 +1235,6 @@ mod tests {
instance,
delete_worktree: false,
delete_branch: false,
delete_sandbox: false,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
@@ -1373,7 +1270,6 @@ mod tests {
instance: retry,
delete_worktree: false,
delete_branch: false,
delete_sandbox: false,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
@@ -1423,7 +1319,6 @@ mod tests {
instance,
delete_worktree: false,
delete_branch: false,
delete_sandbox: false,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
@@ -1488,7 +1383,6 @@ mod tests {
instance,
delete_worktree: true,
delete_branch: false,
delete_sandbox: false,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
@@ -1561,111 +1455,6 @@ mod tests {
assert!(!workspace_dir_is_aoe_owned(&workspace_info("/tmp/ws", &[])));
}
mod container_removal {
use super::*;
use crate::containers::error::DockerError;
use crate::containers::Teardown;
#[test]
fn failure_is_recorded_as_error() {
let mut messages = Vec::new();
let mut errors = Vec::new();
deletion_messages_for(
Teardown::Failed(DockerError::RemoveFailed("daemon busy".into())),
&mut messages,
&mut errors,
);
assert_eq!(
errors.len(),
1,
"a removal failure must be surfaced so the caller keeps the session record"
);
assert!(errors[0].contains("Container"));
assert!(messages.is_empty());
}
#[test]
fn removed_records_message() {
let mut messages = Vec::new();
let mut errors = Vec::new();
deletion_messages_for(Teardown::Removed, &mut messages, &mut errors);
assert_eq!(messages, vec!["Container removed".to_string()]);
assert!(errors.is_empty());
}
#[test]
fn already_gone_is_silent() {
let mut messages = Vec::new();
let mut errors = Vec::new();
deletion_messages_for(Teardown::AlreadyGone, &mut messages, &mut errors);
assert!(
errors.is_empty(),
"an already-gone container is idempotent, not a failure"
);
assert!(
messages.is_empty(),
"no spurious 'removed' message when nothing was removed"
);
}
fn sandboxed_request() -> DeletionRequest {
use crate::session::SandboxInfo;
let mut instance = create_test_instance();
instance.sandbox_info = Some(SandboxInfo {
enabled: true,
container_id: None,
image: "alpine".to_string(),
container_name: "aoe-sandbox-calltest".to_string(),
extra_env: None,
custom_instruction: None,
before_start_env: Vec::new(),
container_workdir: None,
});
DeletionRequest {
session_id: instance.id.clone(),
instance,
delete_worktree: false,
delete_branch: false,
delete_sandbox: true,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
}
}
#[test]
fn call_site_surfaces_teardown_failure() {
// A failed container teardown must surface as an error so the
// caller keeps the session record, not silently succeed. Pins the
// `perform_deletion` call site, not just the mapping helper.
let request = sandboxed_request();
let result = perform_deletion_with(&request, |_id| {
Teardown::Failed(DockerError::RemoveFailed("daemon busy".into()))
});
assert!(!result.success, "a teardown failure must fail the deletion");
assert!(result.errors.iter().any(|e| e.contains("Container")));
}
#[test]
fn call_site_invokes_teardown_unconditionally() {
// Guards against re-introducing an existence-probe gate around the
// teardown: it must run whenever the session is sandboxed and
// delete_sandbox is set.
use std::cell::Cell;
let request = sandboxed_request();
let called = Cell::new(false);
let result = perform_deletion_with(&request, |_id| {
called.set(true);
Teardown::Removed
});
assert!(
called.get(),
"teardown must be invoked unconditionally, never gated behind a probe"
);
assert!(result.success);
}
}
#[test]
fn test_deletion_request_preserves_session_id() {
let instance = create_test_instance();
@@ -1676,7 +1465,6 @@ mod tests {
instance,
delete_worktree: false,
delete_branch: false,
delete_sandbox: false,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
@@ -1688,7 +1476,6 @@ mod tests {
mod ordering {
use super::*;
use crate::session::SandboxInfo;
use std::sync::{Arc, Mutex};
use tracing::field::{Field, Visit};
use tracing::subscriber::with_default;
@@ -1796,79 +1583,6 @@ mod tests {
.unwrap_or_else(|| panic!("stage {:?} missing from {:?}", needle, stages))
}
/// Regression: sandboxed + worktree deletion must drop the
/// container BEFORE touching the worktree directory. The old
/// order (worktree first) raced the still-running in-container
/// agent and produced flaky permission errors and dirty-tree
/// failures.
#[test]
fn sandboxed_with_worktree_kills_tmux_and_container_before_worktree() {
// Synthesize an instance with both worktree_info and an
// (enabled) sandbox_info pointing at a non-existent
// container. The container ops will no-op (container does
// not exist), but the *order* of stage events is still
// emitted, and that's what we're testing.
let mut instance = Instance::new("Test", "/tmp/aoe-deletion-test-nonexistent");
instance.sandbox_info = Some(SandboxInfo {
enabled: true,
container_id: None,
image: "alpine".to_string(),
container_name: "aoe-sandbox-doesnotexist".to_string(),
extra_env: None,
custom_instruction: None,
before_start_env: Vec::new(),
container_workdir: None,
});
let request = DeletionRequest {
session_id: instance.id.clone(),
instance,
delete_worktree: true,
delete_branch: false,
delete_sandbox: true,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
};
let stages = run_with_capture(|| {
let _ = perform_deletion(&request);
});
// tmux_kill must precede container_remove must precede
// worktree_remove must precede branch_delete.
let i_tmux = idx(&stages, "tmux_kill");
let i_container = idx(&stages, "container_remove");
let i_worktree = idx(&stages, "worktree_remove");
let i_branch = idx(&stages, "branch_delete");
assert!(
i_tmux < i_container,
"tmux must be killed before container removal: stages={:?}",
stages
);
assert!(
i_container < i_worktree,
"container must be removed before worktree cleanup: stages={:?}",
stages
);
assert!(
i_worktree < i_branch,
"worktree must be cleaned before branch delete: stages={:?}",
stages
);
// Sandboxed + delete_worktree: we should also see the
// in-container preclean stage between tmux_kill and
// container_remove.
let i_preclean = idx(&stages, "sandbox_worktree_preclean");
assert!(
i_tmux < i_preclean && i_preclean < i_container,
"preclean must run after tmux kill and before container remove: stages={:?}",
stages
);
}
/// End-to-end-on-disk: build a real git repo + worktree on the
/// filesystem (no docker, no tmux session), call
/// `perform_deletion(delete_worktree=true)`, and verify the
@@ -1933,7 +1647,6 @@ mod tests {
instance,
delete_worktree: true,
delete_branch: true,
delete_sandbox: false,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
@@ -2025,7 +1738,6 @@ mod tests {
instance,
delete_worktree: true,
delete_branch: true,
delete_sandbox: false,
force_delete: true,
detach_hooks: true,
keep_scratch: false,
@@ -2141,7 +1853,6 @@ mod tests {
instance,
delete_worktree: true,
delete_branch: false,
delete_sandbox: false,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
@@ -2198,7 +1909,6 @@ mod tests {
instance,
delete_worktree: true,
delete_branch: false,
delete_sandbox: false,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
@@ -2277,7 +1987,6 @@ mod tests {
instance,
delete_worktree: true,
delete_branch: true,
delete_sandbox: false,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
@@ -2350,7 +2059,6 @@ mod tests {
instance,
delete_worktree: true,
delete_branch: true,
delete_sandbox: false,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
@@ -2421,7 +2129,6 @@ mod tests {
instance,
delete_worktree: false,
delete_branch: true,
delete_sandbox: false,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
@@ -2526,7 +2233,6 @@ mod tests {
instance: instance.clone(),
delete_worktree: true,
delete_branch: false,
delete_sandbox: false,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
@@ -2547,7 +2253,6 @@ mod tests {
instance,
delete_worktree: true,
delete_branch: true,
delete_sandbox: false,
force_delete: true,
detach_hooks: true,
keep_scratch: false,
@@ -2613,16 +2318,6 @@ mod tests {
created_at: chrono::Utc::now(),
base_branch: None,
});
instance.sandbox_info = Some(SandboxInfo {
enabled: true,
container_id: None,
image: "alpine".to_string(),
container_name: "aoe-dirty-test-doesnotexist".to_string(),
extra_env: None,
custom_instruction: None,
before_start_env: Vec::new(),
container_workdir: None,
});
(tmp, main_repo, worktree_path, instance)
}
@@ -2647,7 +2342,6 @@ mod tests {
instance,
delete_worktree: true,
delete_branch: true,
delete_sandbox: true,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
@@ -2718,7 +2412,6 @@ mod tests {
instance,
delete_worktree: true,
delete_branch: true,
delete_sandbox: false,
force_delete: true,
detach_hooks: true,
keep_scratch: false,
@@ -2756,7 +2449,6 @@ mod tests {
instance,
delete_worktree: true,
delete_branch: false,
delete_sandbox: false,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
@@ -2827,7 +2519,6 @@ mod tests {
instance,
delete_worktree: true,
delete_branch: true,
delete_sandbox: false,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
@@ -2878,7 +2569,6 @@ mod tests {
instance,
delete_worktree: false,
delete_branch: false,
delete_sandbox: false,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
@@ -2914,7 +2604,6 @@ mod tests {
instance,
delete_worktree: false,
delete_branch: false,
delete_sandbox: false,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
@@ -2947,7 +2636,6 @@ mod tests {
instance,
delete_worktree: false,
delete_branch: false,
delete_sandbox: false,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
@@ -2987,7 +2675,6 @@ mod tests {
instance,
delete_worktree: false,
delete_branch: false,
delete_sandbox: false,
force_delete: false,
detach_hooks: true,
keep_scratch: true,
@@ -3040,7 +2727,6 @@ mod tests {
instance,
delete_worktree: false,
delete_branch: false,
delete_sandbox: false,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -545,7 +545,7 @@ fn group_members<'a>(
///
/// `favorite()` and `archive()` are mutually exclusive today, so a favorited
/// row is normally never archived. The archive/trash guards defend the direct
/// callers (the sort pass and the row renderer's `ViewMode::Tool` arm, which
/// callers (the sort pass and the row renderer, which
/// does not pre-filter those states) against a legacy or hand-edited record
/// that carries both.
pub(crate) fn is_live_favorite(inst: &Instance) -> bool {
+70 -1065
View File
File diff suppressed because it is too large Load Diff
+12 -19
View File
@@ -10,7 +10,6 @@ pub(crate) mod claim;
// every build; only the structured-view import path needs `serve`.
pub mod claude_import;
pub mod config;
pub(crate) mod container_config;
// Depends on `crate::acp` (Event / event store) and is only driven from the
// serve daemon, both of which are serve-gated. See #2808.
#[cfg(feature = "serve")]
@@ -42,10 +41,8 @@ pub mod worktree_edit;
pub use crate::status_hooks::StatusHookConfig;
pub(crate) use capture::is_valid_session_id;
pub use config::{
get_update_settings, load_config, update_app_state, update_config, AgentRuntimeConfig,
AttachMode, ClickAction, Config, ContainerRuntimeName, DefaultTerminalMode, GroupByMode,
RowTagMode, SandboxConfig, SessionConfig, ThemeConfig, TmuxSettingMode, UpdatesConfig,
VolumeIgnoresStrategy, WorktreeConfig,
load_config, update_app_state, update_config, AgentRuntimeConfig, AttachMode, ClickAction,
Config, GroupByMode, RowTagMode, SessionConfig, ThemeConfig, TmuxSettingMode, WorktreeConfig,
};
pub(crate) use environment::user_shell;
pub use environment::{validate_env_entries, validate_env_entry};
@@ -70,9 +67,9 @@ pub(crate) use instance::{
};
pub use instance::{
is_valid_session_color, EnsureReadyError, EnsureReadyOutcome, Instance, LaunchSidOutcome,
LifecycleOperation, LifecycleReservation, LifecycleReservationError, SandboxInfo,
SessionBucket, StartOutcome, Status, TerminalInfo, View, WorkspaceInfo, WorkspaceRepo,
WorktreeInfo, SESSION_COLORS, TMUX_SESSION_GONE_ERROR,
LifecycleOperation, LifecycleReservation, LifecycleReservationError, SessionBucket,
StartOutcome, Status, TerminalInfo, View, WorkspaceInfo, WorkspaceRepo, WorktreeInfo,
SESSION_COLORS, TMUX_SESSION_GONE_ERROR,
};
pub(crate) use storage::acquire_session_identity_lock;
@@ -121,10 +118,9 @@ pub use profile_config::{
pub use projects::{Project, ProjectScope};
pub use recovery::HookTimeoutScope;
pub use repo_config::{
check_repo_trust, execute_hooks, execute_hooks_in_container, load_repo_config,
merge_repo_config, profile_to_repo_config, repo_config_to_profile, resolve_config_with_repo,
resolve_config_with_repo_or_warn, save_repo_config, trust_repo, HookTimeout, HooksConfig,
RepoConfig, RepoTrust, TrustSurface,
check_repo_trust, execute_hooks, load_repo_config, merge_repo_config, profile_to_repo_config,
repo_config_to_profile, resolve_config_with_repo, resolve_config_with_repo_or_warn,
save_repo_config, trust_repo, HookTimeout, HooksConfig, RepoConfig, RepoTrust, TrustSurface,
};
pub use scope::SessionScope;
pub(crate) use storage::{atomic_write, resolve_symlink_chain};
@@ -1180,18 +1176,15 @@ mod tests {
let temp = isolate_app_dir();
let dir = app_dir(&temp);
// `session.custom_agents` is a `HashMap<String,String>` (name -> shell
// command), so it must be an inline table, not a section. The other
// two are the real map-key-plus-struct shapes (`agents.<name>`,
// `tools.<name>`) that exercise the "user-defined section with typed
// contents" pattern.
// command), so it must be an inline table, not a section.
// `agents.<name>` is the real map-key-plus-struct shape that
// exercises the "user-defined section with typed contents" pattern.
fs::write(
dir.join("config.toml"),
"[session]\n\
custom_agents = { myagent = \"true\" }\n\
[agents.claude.status_map]\n\
SessionStart = \"running\"\n\
[tools.lazygit]\n\
command = \"lazygit\"\n",
SessionStart = \"running\"\n",
)
.unwrap();
-114
View File
@@ -342,34 +342,6 @@ mod tests {
assert_eq!(ov["sandbox"]["enabled_by_default"], json!(true));
}
#[test]
fn test_merge_configs_no_overrides() {
let global = Config::default();
let profile = ProfileConfig::default();
let merged = merge_configs(global.clone(), &profile);
assert_eq!(
merged.updates.update_check_mode,
global.updates.update_check_mode
);
assert_eq!(merged.worktree.enabled, global.worktree.enabled);
}
#[test]
fn test_merge_configs_with_overrides() {
use crate::session::config::UpdateCheckMode;
let global = Config::default();
let profile = profile_from(json!({
"updates": {"update_check_mode": "off"},
"worktree": {"enabled": true},
}));
let merged = merge_configs(global, &profile);
assert_eq!(merged.updates.update_check_mode, UpdateCheckMode::Off);
assert!(merged.worktree.enabled);
}
#[test]
fn test_merge_configs_with_status_hook_overrides() {
let mut global = Config::default();
@@ -573,31 +545,6 @@ mod tests {
}
}
#[test]
fn test_merge_configs_with_volume_ignores_override() {
let global = Config::default();
assert!(global.sandbox.volume_ignores.is_empty());
let profile =
profile_from(json!({"sandbox": {"volume_ignores": ["target", "node_modules"]}}));
let merged = merge_configs(global, &profile);
assert_eq!(
merged.sandbox.volume_ignores,
vec!["target", "node_modules"]
);
}
#[test]
fn test_merge_configs_volume_ignores_inherits_when_not_overridden() {
let mut global = Config::default();
global.sandbox.volume_ignores = vec!["target".to_string()];
let profile = profile_from(json!({"sandbox": {"enabled_by_default": true}}));
let merged = merge_configs(global, &profile);
assert_eq!(merged.sandbox.volume_ignores, vec!["target"]);
assert!(merged.sandbox.enabled_by_default);
}
#[test]
fn test_volume_ignores_override_serialization() {
let config = profile_from(json!({"sandbox": {"volume_ignores": ["target", ".venv"]}}));
@@ -641,25 +588,6 @@ mod tests {
assert_eq!(merged.theme.name, "catppuccin-latte");
}
#[test]
fn test_sandbox_override_string_shorthand() {
// Regression: a single string stands in for a one-element list, coerced
// by the target `SandboxConfig`'s `string_or_vec` deserializer on merge.
let toml = r#"
[sandbox]
environment = "ANTHROPIC_API_KEY"
extra_volumes = "/data:/data:ro"
volume_ignores = "node_modules"
port_mappings = "3000:3000"
"#;
let config: ProfileConfig = toml::from_str(toml).unwrap();
let merged = merge_configs(Config::default(), &config);
assert_eq!(merged.sandbox.environment, vec!["ANTHROPIC_API_KEY"]);
assert_eq!(merged.sandbox.extra_volumes, vec!["/data:/data:ro"]);
assert_eq!(merged.sandbox.volume_ignores, vec!["node_modules"]);
assert_eq!(merged.sandbox.port_mappings, vec!["3000:3000"]);
}
#[test]
fn test_hooks_override_string_shorthand() {
// Regression: HooksConfig accepts a plain string, coerced on merge.
@@ -763,48 +691,6 @@ mod tests {
assert_eq!(merged.environment, vec!["FROM_GLOBAL=1".to_string()]);
}
// Replace (not extend) semantics for the Vec sandbox overrides.
#[test]
fn test_merge_configs_replaces_extra_volumes() {
let mut global = Config::default();
global.sandbox.extra_volumes = vec!["/from-global:/g".to_string()];
let profile = profile_from(json!({"sandbox": {"extra_volumes": ["/from-profile:/p"]}}));
let merged = merge_configs(global, &profile);
assert_eq!(merged.sandbox.extra_volumes, vec!["/from-profile:/p"]);
}
#[test]
fn test_merge_configs_extra_volumes_inherits_when_none() {
let mut global = Config::default();
global.sandbox.extra_volumes = vec!["/from-global:/g".to_string()];
let profile = profile_from(json!({"sandbox": {"enabled_by_default": true}}));
let merged = merge_configs(global, &profile);
assert_eq!(merged.sandbox.extra_volumes, vec!["/from-global:/g"]);
}
#[test]
fn test_merge_configs_replaces_port_mappings() {
let mut global = Config::default();
global.sandbox.port_mappings = vec!["3000:3000".to_string()];
let profile =
profile_from(json!({"sandbox": {"port_mappings": ["8080:8080", "9090:9090"]}}));
let merged = merge_configs(global, &profile);
assert_eq!(merged.sandbox.port_mappings, vec!["8080:8080", "9090:9090"]);
}
#[test]
fn test_merge_configs_port_mappings_inherits_when_none() {
let mut global = Config::default();
global.sandbox.port_mappings = vec!["3000:3000".to_string()];
let profile = profile_from(json!({"sandbox": {"cpu_limit": "2"}}));
let merged = merge_configs(global, &profile);
assert_eq!(merged.sandbox.port_mappings, vec!["3000:3000"]);
}
#[test]
fn test_merge_configs_with_acp_overrides() {
let global = Config::default();
+26 -346
View File
@@ -890,14 +890,9 @@ pub fn hook_display_groups(
// ---------------------------------------------------------------------------
/// Where to run a hook command.
enum HookTarget<'a> {
struct HookTarget<'a> {
/// Run locally in the given project directory.
Local { project_path: &'a Path },
/// Run inside a Docker container.
Container {
container_name: &'a str,
workdir: &'a str,
},
project_path: &'a Path,
}
/// Spawn-time options for a hook child process.
@@ -945,59 +940,31 @@ fn build_hook_command(
cmd.to_string()
};
let mut command = match target {
HookTarget::Local { project_path } => {
let shell = super::environment::user_shell();
let mut command = std::process::Command::new(shell);
command.arg("-c").arg(shell_cmd).current_dir(project_path);
for (k, v) in extra_env {
command.env(k, v);
}
command
}
HookTarget::Container {
container_name,
workdir,
} => {
let binary = crate::containers::runtime_binary();
let mut command = std::process::Command::new(binary);
command.arg("exec").arg("--workdir").arg(workdir);
// For container hooks, env vars on the `docker exec` parent do not
// propagate inside the container; inject them via `-e` instead.
for (k, v) in extra_env {
command.arg("-e").arg(format!("{}={}", k, v));
}
if opts.detach_tty {
for (k, v) in PROMPT_SUPPRESS_ENV {
command.arg("-e").arg(format!("{}={}", k, v));
}
}
command
.arg(container_name)
.arg("bash")
.arg("-c")
.arg(&shell_cmd);
command
let mut command = {
let shell = super::environment::user_shell();
let mut command = std::process::Command::new(shell);
command
.arg("-c")
.arg(shell_cmd)
.current_dir(target.project_path);
for (k, v) in extra_env {
command.env(k, v);
}
command
};
if opts.detach_tty {
// Cut every channel a credential prompt could escape through:
// - stdin: don't inherit the TUI's raw-mode terminal
// - prompt-suppression env vars (set on the parent for local hooks;
// forwarded via `-e` above for container hooks)
// - setsid (Unix, local only): no controlling terminal, so /dev/tty
// open fails. Container hooks already run via `docker exec` with
// no TTY allocated.
// - prompt-suppression env vars
// - setsid (Unix): no controlling terminal, so /dev/tty open fails.
command.stdin(std::process::Stdio::null());
if matches!(target, HookTarget::Local { .. }) {
for (k, v) in PROMPT_SUPPRESS_ENV {
command.env(k, v);
}
for (k, v) in PROMPT_SUPPRESS_ENV {
command.env(k, v);
}
#[cfg(unix)]
if matches!(target, HookTarget::Local { .. }) {
{
use std::os::unix::process::CommandExt;
// SAFETY: setsid is async-signal-safe per POSIX, which is the only
// requirement for pre_exec closures.
@@ -1042,21 +1009,10 @@ pub(crate) fn lifecycle_env_vars(instance: &super::Instance) -> Vec<(&'static st
}
/// Format a hook failure error message from captured output.
fn format_hook_error(
cmd: &str,
exit_code: Option<i32>,
stderr: &str,
stdout: &str,
in_container: bool,
) -> String {
let prefix = if in_container {
"Hook command failed in container"
} else {
"Hook command failed"
};
fn format_hook_error(cmd: &str, exit_code: Option<i32>, stderr: &str, stdout: &str) -> String {
let mut detail = format!(
"{} with exit code {}: {}",
prefix,
"Hook command failed",
exit_code.unwrap_or(-1),
cmd
);
@@ -1075,7 +1031,6 @@ fn run_hooks_captured(
target: &HookTarget,
extra_env: &[(&'static str, String)],
) -> Result<()> {
let in_container = matches!(target, HookTarget::Container { .. });
let timeout = crate::session::recovery::current_hook_timeout();
for cmd in commands {
@@ -1099,8 +1054,7 @@ fn run_hooks_captured(
cmd,
output.status.code(),
&stderr,
&stdout,
in_container
&stdout
));
}
@@ -1176,8 +1130,6 @@ fn run_hooks_streamed(
) -> Result<()> {
use std::io::BufRead;
let in_container = matches!(target, HookTarget::Container { .. });
// Streamed lines are consumed live by the progress channel and gone by
// the time a failure dialog renders, so keep a bounded tail to attach to
// the error; it's the only context that survives to the user.
@@ -1218,7 +1170,7 @@ fn run_hooks_streamed(
let status = child.wait()?;
if !status.success() {
let mut detail = format_hook_error(cmd, status.code(), "", "", in_container);
let mut detail = format_hook_error(cmd, status.code(), "", "");
if !tail.is_empty() {
let label = if total_lines > tail.len() {
format!("output (last {} of {} lines)", tail.len(), total_lines)
@@ -1258,24 +1210,7 @@ pub fn execute_hooks(
project_path: &Path,
extra_env: &[(&'static str, String)],
) -> Result<()> {
run_hooks_captured(commands, &HookTarget::Local { project_path }, extra_env)
}
/// Execute hooks inside a Docker container.
pub fn execute_hooks_in_container(
commands: &[String],
container_name: &str,
workdir: &str,
extra_env: &[(&'static str, String)],
) -> Result<()> {
run_hooks_captured(
commands,
&HookTarget::Container {
container_name,
workdir,
},
extra_env,
)
run_hooks_captured(commands, &HookTarget { project_path }, extra_env)
}
/// Resolve `host_hooks.before_start` from global + profile config only.
@@ -1418,7 +1353,7 @@ fn run_env_minting_hooks(
);
let mut command = build_hook_command(
cmd,
&HookTarget::Local { project_path },
&HookTarget { project_path },
HookSpawnOpts {
merge_stderr: false,
detach_tty: true,
@@ -1478,7 +1413,6 @@ fn run_hooks_best_effort(
detach_tty: bool,
extra_env: &[(&'static str, String)],
) -> Vec<String> {
let in_container = matches!(target, HookTarget::Container { .. });
let mut errors = Vec::new();
for cmd in commands {
@@ -1501,13 +1435,7 @@ fn run_hooks_best_effort(
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let err = format_hook_error(
cmd,
output.status.code(),
&stderr,
&stdout,
in_container,
);
let err = format_hook_error(cmd, output.status.code(), &stderr, &stdout);
tracing::warn!(target: "session.store", "{}", err);
errors.push(err);
} else {
@@ -1542,30 +1470,7 @@ pub fn execute_hooks_best_effort(
) -> Vec<String> {
run_hooks_best_effort(
commands,
&HookTarget::Local { project_path },
detach_tty,
extra_env,
)
}
/// Execute hooks in a container with best-effort semantics (all commands attempted).
///
/// `detach_tty` should be true when called from a TUI/web context to keep
/// credential prompts off the UI; false from CLI so prompts remain answerable.
/// Returns a list of error messages for any hooks that failed.
pub fn execute_hooks_in_container_best_effort(
commands: &[String],
container_name: &str,
workdir: &str,
detach_tty: bool,
extra_env: &[(&'static str, String)],
) -> Vec<String> {
run_hooks_best_effort(
commands,
&HookTarget::Container {
container_name,
workdir,
},
&HookTarget { project_path },
detach_tty,
extra_env,
)
@@ -1580,26 +1485,7 @@ pub fn execute_hooks_streamed(
) -> Result<()> {
run_hooks_streamed(
commands,
&HookTarget::Local { project_path },
progress_tx,
extra_env,
)
}
/// Execute hooks inside a Docker container with streamed output.
pub fn execute_hooks_in_container_streamed(
commands: &[String],
container_name: &str,
workdir: &str,
progress_tx: &mpsc::Sender<HookProgress>,
extra_env: &[(&'static str, String)],
) -> Result<()> {
run_hooks_streamed(
commands,
&HookTarget::Container {
container_name,
workdir,
},
&HookTarget { project_path },
progress_tx,
extra_env,
)
@@ -2123,50 +2009,6 @@ mod tests {
assert!(repo.allowed_overrides().get("tmux").is_none());
}
#[test]
fn test_repo_config_cannot_force_a_sandbox_or_pick_its_image() {
// A repo could otherwise put a plain `aoe add` (no --sandbox) into a
// container it chose, with the host filesystem and the user's SSH keys
// mounted in: same no-prompt host compromise as the launch-command
// vector, so the four impactful fields are denied while the rest of the
// section stays team-shareable.
let repo: RepoConfig = toml::from_str(
r#"
[sandbox]
enabled_by_default = true
default_image = "attacker/img"
extra_volumes = ["/:/host:rw"]
mount_ssh = true
memory_limit = "16g"
volume_ignores = ["node_modules"]
"#,
)
.unwrap();
let merged = merge_repo_config(Config::default(), &repo);
assert!(
!merged.sandbox.enabled_by_default,
"a repo must not be able to turn the sandbox on"
);
assert_ne!(merged.sandbox.default_image, "attacker/img");
assert!(merged.sandbox.extra_volumes.is_empty());
assert!(!merged.sandbox.mount_ssh);
// The rest of the section is the point of repo-shared sandbox config.
assert_eq!(merged.sandbox.memory_limit.as_deref(), Some("16g"));
assert_eq!(merged.sandbox.volume_ignores, vec!["node_modules"]);
let (_, rejected) = sanitize_repo_overrides(&repo.overrides);
assert_eq!(
rejected,
vec![
"sandbox.default_image",
"sandbox.enabled_by_default",
"sandbox.extra_volumes",
"sandbox.mount_ssh",
]
);
}
#[test]
fn test_sanitize_repo_overrides_rejects_a_non_object_section() {
// `session = "oops"` cannot be field-filtered, so it fails closed
@@ -2378,30 +2220,6 @@ mod tests {
assert_eq!(merged.session.default_tool, Some("opencode".to_string()));
}
#[test]
fn test_merge_repo_config_sandbox() {
let config = Config::default();
let repo: RepoConfig = serde_json::from_value(serde_json::json!({"sandbox": {
"enabled_by_default": true,
"default_image": "ghcr.io/example/custom:latest",
"volume_ignores": ["node_modules"],
"cpu_limit": "8"
}}))
.unwrap();
let merged = merge_repo_config(config, &repo);
// Sandbox settings that only tune an opted-in container still merge.
assert_eq!(merged.sandbox.volume_ignores, vec!["node_modules"]);
assert_eq!(merged.sandbox.cpu_limit.as_deref(), Some("8"));
// Turning the sandbox on and choosing its image are not the repo's
// call (#3154); the repo-level `default_image` from #1651 is
// deliberately no longer honored.
assert!(!merged.sandbox.enabled_by_default);
assert_ne!(
merged.sandbox.default_image,
"ghcr.io/example/custom:latest"
);
}
#[test]
fn test_merge_repo_config_worktree() {
let config = Config::default();
@@ -2414,18 +2232,6 @@ mod tests {
assert_eq!(merged.worktree.path_template, "../wt/{branch}");
}
#[test]
fn test_merge_repo_config_no_overrides() {
let config = Config::default();
let repo = RepoConfig::default();
let merged = merge_repo_config(config.clone(), &repo);
assert_eq!(merged.worktree.enabled, config.worktree.enabled);
assert_eq!(
merged.sandbox.enabled_by_default,
config.sandbox.enabled_by_default
);
}
#[test]
fn test_load_repo_config_nonexistent() {
let result = load_repo_config(Path::new("/nonexistent/path")).unwrap();
@@ -2625,42 +2431,6 @@ trusted_at = "2026-01-31T00:00:00Z"
assert_eq!(normalized_real, normalized_link);
}
#[test]
fn test_execute_hooks_in_container_fails_gracefully() {
let result = execute_hooks_in_container(
&["echo test".to_string()],
"nonexistent_container",
"/workspace/myproject",
&[],
);
// Should fail because docker/container doesn't exist, but should not panic
assert!(result.is_err());
}
#[test]
fn test_merge_repo_config_preserves_unset_fields() {
let mut config = Config::default();
config.sandbox.enabled_by_default = true;
config.sandbox.auto_cleanup = true;
config.worktree.enabled = true;
config.worktree.auto_cleanup = true;
// Only override one field per section
let repo: RepoConfig = serde_json::from_value(serde_json::json!({
"sandbox": {"auto_cleanup": false},
"worktree": {"enabled": false}
}))
.unwrap();
let merged = merge_repo_config(config, &repo);
// Overridden fields should change
assert!(!merged.sandbox.auto_cleanup);
assert!(!merged.worktree.enabled);
// Non-overridden fields should be preserved
assert!(merged.sandbox.enabled_by_default);
assert!(merged.worktree.auto_cleanup);
}
/// Regression for issue #901: streamed hooks must run detached from the
/// TUI's controlling terminal, so an interactive prompt (e.g., `git clone`
/// over HTTPS asking for a username) cannot reach `/dev/tty` and corrupt
@@ -2817,59 +2587,6 @@ trusted_at = "2026-01-31T00:00:00Z"
);
}
/// Container hooks need prompt-suppression env vars forwarded into the
/// container via `docker exec -e`, since `docker exec` does not pass host
/// env vars by default. This is a structural test: we don't actually run
/// `docker exec` (CI lacks it), we just inspect the args we'd hand to it.
#[test]
fn container_hook_forwards_prompt_env_via_dash_e() {
let target = HookTarget::Container {
container_name: "test_container",
workdir: "/work",
};
let detached = build_hook_command(
"git clone https://example.com/repo",
&target,
HookSpawnOpts {
merge_stderr: true,
detach_tty: true,
},
&[],
);
let args: Vec<String> = detached
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let joined = args.join(" ");
assert!(
joined.contains("-e GIT_TERMINAL_PROMPT=0"),
"expected `-e GIT_TERMINAL_PROMPT=0` in docker exec args, got: {:?}",
args
);
assert!(
joined.contains("-e GIT_ASKPASS=true"),
"expected `-e GIT_ASKPASS=true` in docker exec args, got: {:?}",
args
);
assert!(
joined.contains("-e SSH_ASKPASS=true"),
"expected `-e SSH_ASKPASS=true` in docker exec args, got: {:?}",
args
);
let attached =
build_hook_command("rm -rf /work/build", &target, HookSpawnOpts::default(), &[]);
let attached_args: Vec<String> = attached
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(
!attached_args.iter().any(|a| a == "-e"),
"captured container path must not inject `-e` flags, got: {:?}",
attached_args
);
}
/// on_destroy hooks invoked from the TUI/web (via session::deletion) must
/// also detach from the controlling terminal. Verifies the new
/// `detach_tty` flag on `execute_hooks_best_effort` actually flows through.
@@ -3024,41 +2741,4 @@ trusted_at = "2026-01-31T00:00:00Z"
out
);
}
/// Container hooks must forward session env via `docker exec -e KEY=VALUE`
/// since host env doesn't propagate into `docker exec`. Structural check
/// against the constructed argv (CI has no docker daemon).
#[test]
fn container_hook_forwards_session_env_via_dash_e() {
let target = HookTarget::Container {
container_name: "test_container",
workdir: "/work",
};
let env = vec![
("AOE_SESSION_ID", "s_abc".to_string()),
("AOE_SESSION_TITLE", "My Title".to_string()),
];
let cmd = build_hook_command("true", &target, HookSpawnOpts::default(), &env);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
// Assert directly against the argv vector so a value with spaces ("My
// Title") must be a single argument; a substring match on the joined
// string would pass even if the value were split across two args.
let has_pair = |k: &str, v: &str| -> bool {
args.windows(2)
.any(|w| w[0] == "-e" && w[1] == format!("{}={}", k, v))
};
assert!(
has_pair("AOE_SESSION_ID", "s_abc"),
"expected (-e, AOE_SESSION_ID=s_abc) pair in argv, got: {:?}",
args
);
assert!(
has_pair("AOE_SESSION_TITLE", "My Title"),
"expected (-e, AOE_SESSION_TITLE=My Title) pair as single argv element, got: {:?}",
args
);
}
}
+1 -4
View File
@@ -4,8 +4,7 @@
use super::FieldDescriptor;
use crate::session::config::{
AcpConfig, LoggingConfig, SandboxConfig, SessionConfig, ThemeConfig, TmuxConfig, UpdatesConfig,
WebConfig, WorktreeConfig,
AcpConfig, LoggingConfig, SessionConfig, ThemeConfig, TmuxConfig, WebConfig, WorktreeConfig,
};
use crate::status_hooks::StatusHookConfig;
@@ -13,9 +12,7 @@ use crate::status_hooks::StatusHookConfig;
pub fn schema() -> Vec<FieldDescriptor> {
let mut out = Vec::new();
out.extend(ThemeConfig::settings_descriptors());
out.extend(UpdatesConfig::settings_descriptors());
out.extend(WorktreeConfig::settings_descriptors());
out.extend(SandboxConfig::settings_descriptors());
out.extend(TmuxConfig::settings_descriptors());
out.extend(SessionConfig::settings_descriptors());
out.extend(StatusHookConfig::settings_descriptors());
+16 -529
View File
@@ -49,11 +49,6 @@ pub enum SkipReason {
NotStructured,
Disabled,
NameNotDefault,
/// Sandboxed at all. Smart rename runs inside the container instead, so only
/// `session::conversation_summary` still reports this.
Sandboxed,
/// Sandboxed, with a utility agent other than the session's own.
SandboxRenameAgentMismatch,
NoOneshot,
CommandOverridden,
}
@@ -64,8 +59,6 @@ impl SkipReason {
SkipReason::NotStructured => "not_structured",
SkipReason::Disabled => "disabled",
SkipReason::NameNotDefault => "name_not_default",
SkipReason::Sandboxed => "sandboxed",
SkipReason::SandboxRenameAgentMismatch => "sandbox_rename_agent_mismatch",
SkipReason::NoOneshot => "no_oneshot",
SkipReason::CommandOverridden => "command_overridden",
}
@@ -79,10 +72,6 @@ impl SkipReason {
SkipReason::NotStructured => "Session is not a structured-view session",
SkipReason::Disabled => "Smart rename is disabled in settings",
SkipReason::NameNotDefault => "Session already has a custom name",
SkipReason::Sandboxed => "Not available for sandboxed sessions",
SkipReason::SandboxRenameAgentMismatch => {
"A sandboxed session can only be auto-named by its own agent, because only that agent's credentials are mounted in the container"
}
SkipReason::NoOneshot => "The smart-rename agent has no one-shot mode",
SkipReason::CommandOverridden => "The smart-rename agent's command is overridden",
}
@@ -94,11 +83,6 @@ impl SkipReason {
/// disqualifying reason. `command_override_in_cfg` is whether the profile config
/// replaces this agent's binary; `command` is the instance's launch command (a
/// non-empty value differing from the agent binary is also an override).
///
/// Sandbox state is not an input here: a sandboxed session runs its one-shot
/// inside its own container. The one sandbox rule that does disqualify (a rename
/// agent other than the session's own) needs both tool names, so it lives in
/// [`check_eligible_resolved`].
pub fn check_eligible(
structured: bool,
setting_on: bool,
@@ -150,23 +134,12 @@ pub fn resolve_rename_tool<'a>(session_tool: &'a str, rename_setting: &'a str) -
/// agent's own binary disqualifies it. Both the runtime gate
/// (`try_smart_rename`) and the sidebar `Pending` indicator call this so they
/// cannot drift.
///
/// `sandboxed` gates one rule: a sandboxed session's one-shot runs inside that
/// session's container, and `build_container_config` mounts only the SESSION
/// agent's credential dir there, so a different rename agent would find its
/// binary (the sandbox image ships them all) and then fail to authenticate. Left
/// ungated it would fail on every turn forever, since a non-zero exit
/// deliberately leaves the session un-attempted so a later turn retries.
// One more input than `check_eligible` (the rename-agent setting); a params
// struct would only add boilerplate to the two call sites and the unit tests.
#[allow(clippy::too_many_arguments)]
pub fn check_eligible_resolved(
structured: bool,
setting_on: bool,
title: &str,
session_tool: &str,
rename_setting: &str,
sandboxed: bool,
session_command: &str,
overrides: &HashMap<String, String>,
) -> Result<&'static agents::AgentDef, SkipReason> {
@@ -185,11 +158,6 @@ pub fn check_eligible_resolved(
command,
command_override_in_cfg,
)?;
// After the generic checks, so an unknown `smart_rename_agent` still reports
// NoOneshot rather than implying a mounted-credentials problem.
if sandboxed && rename_tool != session_tool {
return Err(SkipReason::SandboxRenameAgentMismatch);
}
Ok(agent.expect("check_eligible Ok implies a built-in agent"))
}
@@ -532,68 +500,6 @@ pub(crate) async fn run_oneshot(
}
}
/// Where a one-shot runs for this session: the argv to spawn and the host
/// working directory to spawn it in (empty for a container, whose workdir comes
/// from the `exec` itself).
pub(crate) struct OneshotTarget {
pub argv: Vec<String>,
pub cwd: String,
}
/// Resolve the spawn target for a session's one-shot: unchanged on the host,
/// wrapped in a container `exec` when the session is sandboxed.
///
/// The agent runs in the container, so its binary and the credential dir the
/// sandbox mounts for it are the ones the pane already uses; a host spawn would
/// reach neither. `None` means the container cannot take an `exec` right now
/// (stopped, absent, or the runtime could not be inspected). That is transient,
/// so the caller must leave the session un-attempted and let a later turn retry
/// rather than burning its one attempt. A stopped container is never started
/// just to name a session.
pub(crate) async fn resolve_oneshot_target(
session_id: &str,
sandboxed: bool,
container_workdir: &str,
project_path: &str,
argv: Vec<String>,
) -> Option<OneshotTarget> {
if !sandboxed {
return Some(OneshotTarget {
argv,
cwd: project_path.to_string(),
});
}
// `docker inspect` blocks; keep it off the caller's runtime thread, mirroring
// the sandbox install path in `server::api::acp::install_in_container`.
let sid = session_id.to_string();
let probed = tokio::task::spawn_blocking(move || {
let container = crate::containers::DockerContainer::from_session_id(&sid);
(container.probe_running(), container)
})
.await;
let (probe, container) = match probed {
Ok(pair) => pair,
Err(e) => {
tracing::debug!(target: "smart_rename", session = %session_id, "container probe task failed: {e}");
return None;
}
};
match probe {
crate::containers::Probe::Running => Some(OneshotTarget {
argv: container.build_exec_argv(container_workdir, &argv),
cwd: String::new(),
}),
crate::containers::Probe::NotRunning => {
tracing::debug!(target: "smart_rename", session = %session_id, "skip: sandbox container is not running");
None
}
crate::containers::Probe::Unknown(e) => {
tracing::debug!(target: "smart_rename", session = %session_id, "skip: sandbox container state unknown: {e}");
None
}
}
}
/// Whether an automatic renamer may overwrite this session's title: either it
/// is still a default civ name (never explicitly set), or it still matches the
/// last title an auto renamer wrote. A manual rename leaves `title` diverged
@@ -658,7 +564,6 @@ pub fn maybe_spawn_terminal_smart_rename(inst: &crate::session::instance::Instan
&inst.title,
&inst.tool,
cfg.rename_agent,
inst.is_sandboxed(),
&inst.command,
cfg.overrides,
)
@@ -1054,29 +959,18 @@ pub async fn run_terminal_rename(
let storage = crate::session::storage::Storage::open_unwatched(profile)?;
let (instances, _groups) = storage.load_with_groups()?;
let Some((
title,
tool,
command,
project_path,
sandboxed,
container_workdir,
detect_as,
already,
structured,
)) = instances.iter().find(|i| i.id == session_id).map(|i| {
(
i.title.clone(),
i.tool.clone(),
i.command.clone(),
i.project_path.clone(),
i.is_sandboxed(),
i.container_workdir(),
i.detect_as.clone(),
i.smart_rename_attempted,
i.is_structured(),
)
})
let Some((title, tool, command, project_path, detect_as, already, structured)) =
instances.iter().find(|i| i.id == session_id).map(|i| {
(
i.title.clone(),
i.tool.clone(),
i.command.clone(),
i.project_path.clone(),
i.detect_as.clone(),
i.smart_rename_attempted,
i.is_structured(),
)
})
else {
return Ok(());
};
@@ -1106,7 +1000,6 @@ pub async fn run_terminal_rename(
&title,
&tool,
cfg.rename_agent,
sandboxed,
&command,
cfg.overrides,
) {
@@ -1153,21 +1046,7 @@ pub async fn run_terminal_rename(
let Some(argv) = build_oneshot_argv(agent, &prompt, model) else {
return Ok(());
};
let Some(target) = resolve_oneshot_target(
session_id,
sandboxed,
&container_workdir,
&project_path,
argv,
)
.await
else {
// Container not usable right now: transient, so leave the session
// un-attempted for a later idle edge.
return Ok(());
};
let Some(raw) = run_oneshot(session_id, &target.argv, &target.cwd, ONESHOT_TIMEOUT).await
else {
let Some(raw) = run_oneshot(session_id, &argv, &project_path, ONESHOT_TIMEOUT).await else {
// Transient failure (spawn / timeout / non-zero exit): leave the session
// un-attempted so a later turn can retry.
return Ok(());
@@ -1277,16 +1156,7 @@ mod serve {
return;
}
let Some((
profile,
tool,
command,
project_path,
sandboxed,
container_workdir,
title,
structured,
)) = ({
let Some((profile, tool, command, project_path, title, structured)) = ({
let instances = state.instances.read().await;
instances.iter().find(|i| i.id == session_id).map(|i| {
(
@@ -1294,14 +1164,11 @@ mod serve {
i.tool.clone(),
i.command.clone(),
i.project_path.clone(),
i.is_sandboxed(),
i.container_workdir(),
i.title.clone(),
i.is_structured(),
)
})
})
else {
}) else {
return;
};
@@ -1316,7 +1183,6 @@ mod serve {
&title,
&tool,
cfg.rename_agent,
sandboxed,
&command,
cfg.overrides,
) {
@@ -1353,24 +1219,11 @@ mod serve {
// early-return paths above never consume a slot. Same-session duplicates
// are already rejected by the InflightGuard, so this permit only gates
// cross-session concurrency (#2348).
let Some(target) = resolve_oneshot_target(
&session_id,
sandboxed,
&container_workdir,
&project_path,
argv,
)
.await
else {
// Container not usable right now: transient, so leave the session
// un-attempted for a later turn.
return;
};
let raw = {
let Ok(_permit) = state.smart_rename_semaphore.acquire().await else {
return;
};
run_oneshot(&session_id, &target.argv, &target.cwd, ONESHOT_TIMEOUT).await
run_oneshot(&session_id, &argv, &project_path, ONESHOT_TIMEOUT).await
};
let Some(raw) = raw else {
return;
@@ -1756,96 +1609,6 @@ mod tests {
assert_eq!(argv, vec!["claude", "-p", "--model", "haiku", "hello"]);
}
#[test]
fn sandboxed_session_is_eligible_for_its_own_agent() {
// #3159: a sandboxed session used to be rejected outright. Its one-shot
// now runs inside its container, where that agent's credentials are
// mounted, so it is eligible like any other session.
let overrides = HashMap::new();
assert!(
check_eligible_resolved(true, true, "Vikings", "claude", "", true, "", &overrides)
.is_ok()
);
}
#[test]
fn sandboxed_session_rejects_a_different_rename_agent() {
// Only the session agent's credential dir is mounted in the container
// (see build_container_config), so `codex` would resolve its binary from
// the sandbox image and then fail to authenticate, on every turn.
let overrides = HashMap::new();
assert!(matches!(
check_eligible_resolved(true, true, "Vikings", "claude", "codex", true, "", &overrides),
Err(SkipReason::SandboxRenameAgentMismatch)
));
// An unsupported rename agent still reports the more specific reason, so
// the message does not blame credential mounting for a bad setting.
assert!(matches!(
check_eligible_resolved(
true, true, "Vikings", "claude", "cursor", true, "", &overrides
),
Err(SkipReason::NoOneshot)
));
// A host session may still borrow a different rename agent.
assert!(check_eligible_resolved(
true, true, "Vikings", "claude", "codex", false, "", &overrides
)
.is_ok());
}
#[tokio::test]
async fn host_session_spawns_the_agent_binary_in_the_project_dir() {
let argv = vec!["claude".to_string(), "-p".to_string(), "hi".to_string()];
let target = resolve_oneshot_target("abc123", false, "/workspace", "/repo", argv.clone())
.await
.expect("host target");
assert_eq!(target.argv, argv, "a host one-shot must not be wrapped");
assert_eq!(target.cwd, "/repo");
}
#[tokio::test]
async fn sandboxed_session_spawns_through_the_container_runtime() {
// Without a live container the probe returns NotRunning or Unknown, which
// must yield no target at all: that keeps the session un-attempted so a
// later turn retries, instead of silently spawning the agent on the host
// where the sandbox's credentials are not mounted.
assert!(
resolve_oneshot_target(
"nosuchsession",
true,
"/workspace",
"/repo",
vec!["claude".to_string()],
)
.await
.is_none(),
"a sandboxed session with no usable container must not fall back to the host"
);
}
#[test]
fn sandboxed_target_wraps_the_agent_argv_for_the_container() {
// The wrapping itself, without needing a running container: the runtime
// binary leads, the container workdir is explicit, and the agent argv
// (prompt included) is carried through unchanged.
let container = crate::containers::DockerContainer::from_session_id("abc12345");
let argv = vec![
"claude".to_string(),
"-p".to_string(),
"name this: $(id)".to_string(),
];
let wrapped = container.build_exec_argv("/workspace/repo", &argv);
assert_ne!(wrapped[0], "claude", "must spawn the container runtime");
assert!(wrapped.contains(&"exec".to_string()));
assert!(wrapped.contains(&"aoe-sandbox-abc12345".to_string()));
assert!(wrapped.contains(&"/workspace/repo".to_string()));
assert_eq!(
&wrapped[wrapped.len() - argv.len()..],
&argv[..],
"the agent argv must survive verbatim as the trailing elements"
);
}
#[test]
fn every_skip_reason_has_a_user_message() {
// Every variant, kept exhaustive by the compiler: a new `SkipReason` has
@@ -1855,8 +1618,6 @@ mod tests {
SkipReason::NotStructured
| SkipReason::Disabled
| SkipReason::NameNotDefault
| SkipReason::Sandboxed
| SkipReason::SandboxRenameAgentMismatch
| SkipReason::NoOneshot
| SkipReason::CommandOverridden => (),
};
@@ -1864,8 +1625,6 @@ mod tests {
SkipReason::NotStructured,
SkipReason::Disabled,
SkipReason::NameNotDefault,
SkipReason::Sandboxed,
SkipReason::SandboxRenameAgentMismatch,
SkipReason::NoOneshot,
SkipReason::CommandOverridden,
]
@@ -1879,65 +1638,6 @@ mod tests {
}
}
#[test]
fn manual_force_bypasses_only_the_disabled_gate() {
// The manual "Auto-name now" action calls check_eligible with
// `setting_on = cfg.setting_on || force`. `auto` is the (off) setting,
// `force` is the manual flag; the automatic path is `auto` alone,
// the manual path is `auto || force`. Both are bindings, not literals,
// so the boolean expression documents the real call shape (#3039).
let c = Some(claude());
let auto = false;
let force = true;
assert_eq!(
check_eligible(true, auto, "Vikings", c, "", false),
Err(SkipReason::Disabled),
"automatic path must still honor the disabled setting"
);
assert!(
check_eligible(true, auto || force, "Vikings", c, "", false).is_ok(),
"manual force must bypass the disabled gate"
);
// Forcing past Disabled must not smuggle past any other gate: an
// otherwise-ineligible session is still rejected on the forced path.
assert!(
matches!(
check_eligible_resolved(
true,
auto || force,
"Vikings",
"claude",
"codex",
true,
"",
&HashMap::new()
),
Err(SkipReason::SandboxRenameAgentMismatch)
),
"sandbox rename-agent gate still applies when forced"
);
assert_eq!(
check_eligible(true, auto || force, "Fix login bug", c, "", false),
Err(SkipReason::NameNotDefault),
"already-named gate still applies when forced"
);
assert_eq!(
check_eligible(false, auto || force, "Vikings", c, "", false),
Err(SkipReason::NotStructured),
"structured gate still applies when forced"
);
assert_eq!(
check_eligible(true, auto || force, "Vikings", None, "", false),
Err(SkipReason::NoOneshot),
"no-one-shot gate still applies when forced"
);
assert_eq!(
check_eligible(true, auto || force, "Vikings", c, "", true),
Err(SkipReason::CommandOverridden),
"command-override gate still applies when forced"
);
}
#[test]
fn argv_codex_skips_git_repo_check_with_prompt_last() {
// codex `exec` refuses to run outside a git repo without this flag, so a
@@ -2052,90 +1752,6 @@ mod tests {
assert_eq!(resolve_rename_tool("claude", " codex "), "codex");
}
#[test]
fn resolved_unset_uses_session_agent() {
let overrides = HashMap::new();
// Unset rename agent => resolves to the session's claude agent.
let agent =
check_eligible_resolved(true, true, "Vikings", "claude", "", false, "", &overrides)
.expect("eligible");
assert_eq!(agent.binary, "claude");
}
#[test]
fn resolved_picks_distinct_rename_agent() {
let overrides = HashMap::new();
let agent = check_eligible_resolved(
true, true, "Vikings", "claude", "codex", false, "", &overrides,
)
.expect("eligible");
assert_eq!(agent.binary, "codex");
}
#[test]
fn resolved_override_gate_targets_the_right_agent() {
// A session-agent command override only blocks when the rename agent IS
// the session agent.
let mut overrides = HashMap::new();
overrides.insert("claude".to_string(), "my-wrapper".to_string());
assert!(matches!(
check_eligible_resolved(true, true, "Vikings", "claude", "", false, "", &overrides),
Err(SkipReason::CommandOverridden)
));
// ...but when the rename agent is a DIFFERENT agent (codex), the
// session's claude override is irrelevant: the one-shot launches codex
// fresh, so it stays eligible.
assert!(check_eligible_resolved(
true, true, "Vikings", "claude", "codex", false, "", &overrides
)
.is_ok());
// An override of the RENAME agent's own binary does block it.
let mut codex_override = HashMap::new();
codex_override.insert("codex".to_string(), "my-codex".to_string());
assert!(matches!(
check_eligible_resolved(
true,
true,
"Vikings",
"claude",
"codex",
false,
"",
&codex_override
),
Err(SkipReason::CommandOverridden)
));
}
#[test]
fn resolved_session_command_ignored_for_distinct_rename_agent() {
// The instance's launch command (for the session agent) must not be
// matched against a different rename agent's binary.
let overrides = HashMap::new();
assert!(check_eligible_resolved(
true, true, "Vikings", "opencode", "claude", false, "opencode", &overrides
)
.is_ok());
}
#[test]
fn resolved_unknown_rename_agent_is_no_oneshot() {
let overrides = HashMap::new();
assert!(matches!(
check_eligible_resolved(
true,
true,
"Vikings",
"claude",
"not-a-real-agent",
false,
"",
&overrides
),
Err(SkipReason::NoOneshot)
));
}
// Reproduces the real Claude Code v2.1.216 startup banner shape: a
// two-column box titled `Claude Code v<ver>` (identity + logo on the left,
// tips / what's-new on the right), then a `⚠ ... MCP servers ...` notice,
@@ -2332,137 +1948,8 @@ Rewrote the getting-started section and fixed two broken links.";
assert!(sanitize_title("12345", "x").is_none());
}
// Regression for #2351: pins the shared helper that both `try_smart_rename`
// and the sidebar indicator overlay in `src/server/api/sessions.rs` route
// through. The helper is verified in isolation here; call-site coverage is
// design-level (reverting either site to bypass the helper is visible in
// review because both explicitly name `resolve_smart_rename_config`).
//
// Also pins the repo boundary from #3154: the utility agent and the command
// override are global/profile only, so a checked-out repo cannot redirect
// the one-shot at another agent or swap the binary it launches.
#[test]
#[serial_test::serial]
fn resolve_smart_rename_config_reads_repo_aware_config_but_not_repo_commands() {
let home = tempfile::tempdir().expect("tempdir HOME");
// SAFETY: serialized by `#[serial]`; matches `set_tmp_home` in
// the settings surfaces.
unsafe {
std::env::set_var("HOME", home.path());
std::env::set_var("XDG_CONFIG_HOME", home.path().join(".config"));
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
let app_dir = home
.path()
.join(".config")
.join(crate::session::APP_DIR_NAME_XDG);
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
let app_dir = home.path().join(crate::session::APP_DIR_NAME_OTHER);
std::fs::create_dir_all(&app_dir).unwrap();
std::fs::write(
app_dir.join("config.toml"),
r#"
[session]
smart_rename_agent = "opencode"
[session.agent_command_override]
claude = "my-wrapper"
"#,
)
.unwrap();
let repo = tempfile::tempdir().expect("tempdir repo");
let cfg_dir = repo.path().join(".agent-of-empires");
std::fs::create_dir_all(&cfg_dir).unwrap();
std::fs::write(
cfg_dir.join("config.toml"),
r#"
[session]
default_tool = "codex"
smart_rename_agent = "gemini"
[session.agent_command_override]
claude = "repo-wrapper"
"#,
)
.unwrap();
let resolved =
crate::session::repo_config::resolve_config_with_repo_or_warn("default", repo.path());
// Pins that the repo file was actually discovered: an allowed field
// from it lands, so the assertions below are about the boundary and
// not about a fixture that silently never loaded.
assert_eq!(resolved.session.default_tool.as_deref(), Some("codex"));
let cfg = resolve_smart_rename_config(&resolved.session);
assert_eq!(
cfg.rename_agent, "opencode",
"the user's utility agent wins; a repo cannot redirect the one-shot"
);
assert_eq!(
cfg.overrides.get("claude").map(String::as_str),
Some("my-wrapper"),
"a repo cannot replace the binary the one-shot launches"
);
let agent = check_eligible_resolved(
true,
cfg.setting_on,
"Vikings",
"claude",
cfg.rename_agent,
false,
"",
cfg.overrides,
)
.expect("eligible");
assert_eq!(agent.binary, "opencode");
}
// ---- Terminal (non-ACP) smart rename ----
#[test]
fn terminal_eligibility_reasons() {
// A terminal session is not "structured", but the terminal trigger is a
// deliberate opt-in, so the call site passes `true`; the remaining gates
// (user story 3) still keep the civ name where they must.
let overrides = HashMap::new();
assert!(check_eligible_resolved(
true, true, "Vikings", "claude", "", false, "", &overrides
)
.is_ok());
// A sandboxed terminal session is eligible for its own agent (#3159);
// only a different rename agent is not.
assert!(
check_eligible_resolved(true, true, "Vikings", "claude", "", true, "", &overrides)
.is_ok()
);
assert!(matches!(
check_eligible_resolved(true, true, "Vikings", "cursor", "", false, "", &overrides),
Err(SkipReason::NoOneshot)
));
let mut ov = HashMap::new();
ov.insert("claude".to_string(), "my-wrapper".to_string());
assert!(matches!(
check_eligible_resolved(true, true, "Vikings", "claude", "", false, "", &ov),
Err(SkipReason::CommandOverridden)
));
// A manually-named session (user story 2) is never a candidate.
assert!(matches!(
check_eligible_resolved(
true,
true,
"Fix login bug",
"claude",
"",
false,
"",
&overrides
),
Err(SkipReason::NameNotDefault)
));
}
#[test]
fn context_usable_rejects_garbage() {
assert!(context_looks_usable("Fix the login bug in auth.rs"));
+5 -178
View File
@@ -16,9 +16,6 @@ use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use crate::git::GitWorktree;
use crate::session::worktree_edit::{
discard_sandbox_container_after_move, ensure_sandbox_container_released,
};
use crate::session::Instance;
/// Hidden, product-owned holding directory for trashed worktrees. A relocated
@@ -99,10 +96,6 @@ fn is_protected_default_branch(inst: &Instance) -> bool {
.is_ok_and(|names| names.contains(&wt.branch))
}
fn is_sandboxed(inst: &Instance) -> bool {
inst.sandbox_info.as_ref().is_some_and(|s| s.enabled)
}
/// Move a freshly-trashed session's managed worktree into the holding area and
/// repoint `project_path`, capturing the original location in
/// `pre_trash_project_path`. The caller MUST have stopped the live agent first
@@ -144,13 +137,6 @@ pub fn relocate_worktree_to_trash(inst: &mut Instance) -> RelocateOutcome {
reason: format!("trash holding path {} already exists", target.display()),
};
}
if ensure_sandbox_container_released(&inst.id, is_sandboxed(inst)) {
return RelocateOutcome::Failed {
reason: "sandbox container still holds the worktree; stop the session first"
.to_string(),
};
}
let main_repo = inst
.worktree_info
.as_ref()
@@ -177,7 +163,6 @@ pub fn relocate_worktree_to_trash(inst: &mut Instance) -> RelocateOutcome {
};
}
discard_sandbox_container_after_move(&inst.id, is_sandboxed(inst));
inst.pre_trash_project_path = Some(inst.project_path.clone());
inst.project_path = target.to_string_lossy().into_owned();
tracing::info!(
@@ -193,64 +178,19 @@ pub fn relocate_worktree_to_trash(inst: &mut Instance) -> RelocateOutcome {
}
}
/// Bring a freshly-trashed session's sandbox container down, then relocate its
/// worktree into the holding area.
///
/// This is the container + worktree half of trashing (`trash_session_by_id`),
/// split from [`relocate_worktree_to_trash`] because trashing must first stop
/// the sandbox container. A sandbox container runs `sleep infinity` for the
/// life of the session and bind-mounts the worktree dir, so trashing without a
/// stop leaves it running for the whole retention window and its live mount
/// makes the relocation's `git worktree move` fail `EBUSY` (the row then stays
/// in the active dir). Stopping it releases the mount so the relocation's own
/// [`discard_sandbox_container_after_move`] can then drop it entirely.
/// Relocate a freshly-trashed session's worktree into the holding area.
///
/// `relocate_worktree_to_trash` alone is still the right call for the reconcile
/// passes (they run on load against already-stopped rows); only the trash
/// *action*, where the container is still live, needs the stop.
/// *action* needs this wrapper.
///
/// The container stop is injected so the sandbox path is exercisable without a
/// live docker runtime (mirrors `deletion::perform_deletion_with`).
///
/// The container stop blocks for up to the stop grace period (~10s), which is
/// plenty of time for a restore to land on the durable row (a user who hit `d`
/// by accident restores immediately; the restore itself is a NoChange because
/// no relocation has been recorded yet). The durable row is therefore
/// re-checked between the stop and the move, and the move is skipped when the
/// row is no longer trashed, was seized by a fresh purge/restore claim, is
/// gone, or storage cannot be read (fail closed, since a skipped move on a
/// still-trashed row is healed by the next reconcile pass, while a move on a
/// restored row strands a live session's worktree in the holding area). The
/// re-check reads storage via `inst.source_profile`, so callers must pass an
/// instance whose profile is stamped and must have durably trashed the row
/// before calling.
///
/// BLOCKING: the container stop shells out to `docker stop` (~10s grace period)
/// and the relocation runs `git worktree move`, so never call this on an event
/// loop / UI thread. The TUI goes through [`perform_trash`] on the
/// BLOCKING: the relocation runs `git worktree move`, so never call this on an
/// event loop / UI thread. The TUI goes through [`perform_trash`] on the
/// `TrashPoller`, the server wraps it in `spawn_blocking`, and the CLI is a
/// one-shot process.
/// Stop the sandbox container and move a managed worktree into the trash.
///
/// The caller must hold the session's lifecycle flock and own its Trash reservation.
pub fn prepare_trashed_worktree(inst: &mut Instance) -> RelocateOutcome {
if let Err(error) =
crate::session::worktree_edit::stop_sandbox_container(&inst.id, is_sandboxed(inst))
{
tracing::warn!(
target: "session.trash",
session = %inst.id,
"stopping sandbox container before trash relocation failed: {error}"
);
}
relocate_worktree_to_trash(inst)
}
#[cfg(test)]
fn prepare_trashed_worktree_with(
inst: &mut Instance,
stop_container: impl FnOnce(&str, bool),
) -> RelocateOutcome {
stop_container(&inst.id, is_sandboxed(inst));
relocate_worktree_to_trash(inst)
}
@@ -383,12 +323,6 @@ pub fn restore_worktree_location(inst: &mut Instance) -> RestoreOutcome {
inst.pre_trash_project_path = None;
return RestoreOutcome::NoChange;
}
if ensure_sandbox_container_released(&inst.id, is_sandboxed(inst)) {
return RestoreOutcome::Failed {
reason: "sandbox container still holds the worktree; stop the session first"
.to_string(),
};
}
if original.exists() {
return RestoreOutcome::Failed {
reason: format!(
@@ -415,7 +349,6 @@ pub fn restore_worktree_location(inst: &mut Instance) -> RestoreOutcome {
reason: format!("git worktree move: {e}"),
};
}
discard_sandbox_container_after_move(&inst.id, is_sandboxed(inst));
inst.project_path = original.to_string_lossy().into_owned();
inst.pre_trash_project_path = None;
tracing::info!(
@@ -1109,7 +1042,6 @@ mod tests {
instance: inst.clone(),
delete_worktree: true,
delete_branch: true,
delete_sandbox: false,
force_delete: true,
detach_hooks: true,
keep_scratch: false,
@@ -1167,7 +1099,6 @@ mod tests {
instance: inst.clone(),
delete_worktree: true,
delete_branch: true,
delete_sandbox: false,
force_delete: true,
detach_hooks: true,
keep_scratch: false,
@@ -1184,101 +1115,6 @@ mod tests {
);
}
/// Regression (#the-d-key): trashing must run the sandbox container-stop
/// step BEFORE relocating the worktree. Before the fix, `trash_session_by_id`
/// only killed tmux and called `relocate_worktree_to_trash` directly, so a
/// sandbox container was left running for the whole retention window and its
/// live bind mount made this very relocation fail EBUSY. The container stop
/// is injected here so the wiring/ordering is verified without a live docker
/// runtime; a non-sandbox session exercises the happy path end to end.
#[test]
fn trash_prep_stops_container_before_relocating() {
if !git_available() {
return;
}
let (_tmp, mut inst) = real_worktree_instance();
inst.trash();
let original = PathBuf::from(&inst.project_path);
use std::cell::Cell;
use std::rc::Rc;
let stop_calls = Rc::new(Cell::new(0u32));
let saw_sandbox_flag = Rc::new(Cell::new(true));
let original_present_at_stop = Rc::new(Cell::new(false));
let outcome = {
let stop_calls = Rc::clone(&stop_calls);
let saw_sandbox_flag = Rc::clone(&saw_sandbox_flag);
let original_present_at_stop = Rc::clone(&original_present_at_stop);
let original = original.clone();
prepare_trashed_worktree_with(&mut inst, move |_id, is_sandboxed| {
stop_calls.set(stop_calls.get() + 1);
saw_sandbox_flag.set(is_sandboxed);
original_present_at_stop.set(original.exists());
})
};
assert_eq!(
stop_calls.get(),
1,
"trash must run the container-stop step exactly once"
);
assert!(
!saw_sandbox_flag.get(),
"a non-sandbox session reports is_sandboxed=false to the stop step"
);
assert!(
original_present_at_stop.get(),
"the container stop must run BEFORE the worktree is moved"
);
assert!(
matches!(outcome, RelocateOutcome::Relocated { .. }),
"relocation still succeeds after the stop step: {outcome:?}"
);
let holding = trash_holding_path(&original, &inst.id).unwrap();
assert_eq!(PathBuf::from(&inst.project_path), holding);
assert!(holding.exists(), "worktree moved into the holding area");
assert!(!original.exists(), "worktree left its original active path");
}
/// A sandboxed session hands `is_sandboxed = true` to the container-stop
/// step. Uses a plain (non-worktree) session so the relocation short-circuits
/// to `Skipped` without touching a real docker runtime; the seam still fires
/// first, which is what proves the flag is wired through.
#[test]
fn trash_prep_passes_sandbox_flag_to_container_stop() {
let mut inst = Instance::new("sandboxed", "/tmp/sandboxed");
inst.sandbox_info = Some(crate::session::SandboxInfo {
enabled: true,
container_id: None,
image: "ubuntu:latest".to_string(),
container_name: "aoe-sandbox-test".to_string(),
extra_env: None,
custom_instruction: None,
before_start_env: Vec::new(),
container_workdir: None,
});
inst.trash();
use std::cell::Cell;
use std::rc::Rc;
let saw_sandbox_flag = Rc::new(Cell::new(false));
let outcome = {
let saw_sandbox_flag = Rc::clone(&saw_sandbox_flag);
prepare_trashed_worktree_with(&mut inst, move |_id, is_sandboxed| {
saw_sandbox_flag.set(is_sandboxed);
})
};
assert!(
saw_sandbox_flag.get(),
"a sandboxed session must report is_sandboxed=true to the stop step"
);
assert!(
matches!(outcome, RelocateOutcome::Skipped),
"a plain session has no managed worktree to relocate: {outcome:?}"
);
}
/// A relocation that lands after the row was restored (the not-atomic
/// window between the worker's still-trashed re-check and its move) is
/// undone: the worktree moves back to the original path the live row
@@ -1321,13 +1157,4 @@ mod tests {
"holding area copy must be gone"
);
}
/// The container-stop helper is a no-op (and never shells out) when the
/// session is not sandboxed, so trashing a plain session stays docker-free.
#[test]
fn stop_sandbox_container_is_noop_when_not_sandboxed() {
assert!(
crate::session::worktree_edit::stop_sandbox_container("no-such-session", false).is_ok()
);
}
}
-234
View File
@@ -22,7 +22,6 @@
use std::path::{Path, PathBuf};
use crate::containers::{DockerContainer, Probe, Teardown};
use crate::git::error::GitError;
use crate::git::template::sanitize_branch_name;
use crate::git::GitWorktree;
@@ -76,21 +75,6 @@ pub(crate) fn derived_worktree_path(current_path: &Path, title: &str) -> String
/// Whether a workdir edit for `new_name` would actually move the worktree
/// directory.
///
/// Callers must gate [`ensure_sandbox_container_released`] on this. That helper
/// removes a stopped sandbox container to free the bind mount, which is only
/// worth doing when the rename is going to `rename(2)` the directory. Three
/// cases reach these endpoints without moving anything, and each one would
/// otherwise destroy a container for no reason:
///
/// - a name that sanitizes to the leaf the worktree already has,
/// - a no-op edit submitting the current name unchanged,
/// - a branch-only edit (`rename_branch` with the title or name unchanged),
/// which [`edit_worktree_workdir`] handles without touching the path.
///
/// The post-move `discard_sandbox_container_after_move` call already gates on
/// `path != current_path` for the same reason; this is the pre-move half of
/// that rule. See #3171 review.
///
/// An empty name is reported as "no move": `edit_worktree_workdir` rejects it
/// with `EmptyName` before doing anything.
pub fn worktree_move_required(current_path: &Path, new_name: &str) -> bool {
@@ -121,193 +105,6 @@ pub fn worktree_branch_rename_required(
rename_branch && git_sanitize_branch_name(new_name) != worktree_info.branch
}
/// Release a sandbox session's hold on its worktree directory ahead of a
/// `git worktree move`, and report whether the worktree is *still* held.
///
/// `true` means the caller must refuse the move.
///
/// A sandbox container bind-mounts the worktree dir. `git worktree move`
/// `rename(2)`s that dir, and the mount holder makes the rename fail: as
/// `EBUSY` on Linux, and as `EACCES` ("Permission denied") on Docker
/// Desktop for macOS, where the bind is re-exported through the VM's
/// file-sharing layer. Either way git surfaces `fatal: failed to move`.
///
/// Two cases, and the distinction is the whole point of this function:
///
/// - **Running.** The agent is live in there; we can't yank its mount out
/// from under it. Report held and let the caller tell the user to stop
/// the session first.
/// - **Stopped but still present.** `docker stop` does *not* release the
/// bind on Docker Desktop, so the rename fails exactly as it would
/// against a live container, but there is nothing to protect. Discard
/// the container here, which drops the mount, and report not-held. The
/// container is recreated with the new path on next start (see
/// [`discard_sandbox_container_after_move`], which the caller still
/// invokes post-move and which no-ops as `AlreadyGone` when we got here
/// first).
///
/// Before this, the gate tested `probe_running()` alone, so a session the
/// user had just stopped sailed past it and hit the rename failure the gate
/// exists to prevent: trashing a stopped sandboxed session logged
/// `trash worktree relocation skipped: ... Permission denied` and left the
/// worktree in place until a later daemon reconcile happened to remove the
/// container first. See #1927 follow-up, #2596, and #3171.
///
/// `is_sandboxed` is taken so non-sandbox sessions skip the `docker inspect`
/// subprocess entirely.
///
/// Fails closed on a transient `docker inspect` failure: a [`Probe::Unknown`]
/// answer is treated as "possibly running" and blocks the rename, rather
/// than swallowing the failure into a false negative (`unwrap_or(false)`)
/// that would let the rename proceed against a live container. This function
/// is *the* barrier that stops the failed rename, not a best-effort post-move
/// cleanup.
pub fn ensure_sandbox_container_released(session_id: &str, is_sandboxed: bool) -> bool {
if !is_sandboxed {
return false;
}
let container = DockerContainer::from_session_id(session_id);
match container.probe_running() {
Probe::Running => true,
Probe::NotRunning => {
// Stopped, but a surviving container still pins the bind mount.
// Dropping it now is what makes the rename succeed. Non-force, so a
// container that came back up between the probe above and here is
// refused rather than force-killed: only the two server callers hold
// `instance_lock`, and the CLI, TUI, and trash paths race a daemon
// reconcile or a Start from the dashboard.
match container.discard_if_stopped() {
Teardown::Removed => {
tracing::info!(
target: "containers.runtime",
session = %session_id,
"removed stopped sandbox container to release its bind mount before the worktree move"
);
false
}
Teardown::AlreadyGone => false,
// Couldn't drop it, so assume it still holds the mount and
// fail the rename with a real reason instead of letting git
// fail with a bare `Permission denied`.
Teardown::Failed(e) => {
tracing::warn!(
target: "containers.runtime",
session = %session_id,
error = %e,
"failed to remove stopped sandbox container before the worktree move; reporting the worktree as held"
);
true
}
}
}
Probe::Unknown(e) => {
tracing::warn!(
target: "containers.runtime",
session = %session_id,
error = %e,
"docker inspect failed while probing sandbox container for the worktree rename gate; failing closed and reporting the worktree as held to prevent a failed rename against a possibly-live container"
);
true
}
}
}
/// Drop a sandbox session's container after its worktree directory has been
/// moved by a rename.
///
/// A container's bind mounts and working dir are baked in at creation time
/// (`src/containers/runtime_base.rs`); they do NOT follow a host-side
/// `git worktree move`. `get_container_for_instance` reuses an existing
/// stopped container as-is, so without this the restarted container would
/// still mount (and `cd` into) the old path. [`DockerContainer::discard`]
/// forces a fresh `create` with the new path on next start while preserving
/// the session's named ignore volumes (`target/`, `node_modules/`) so the
/// recreated container re-attaches its build caches.
///
/// No-op for non-sandbox sessions, and commonly a no-op
/// ([`Teardown::AlreadyGone`]) on the sandbox path too: the rename gate
/// ([`ensure_sandbox_container_released`]) already discards a stopped
/// container to free the bind mount, so this call is what covers the
/// remaining case of a container that reappeared, plus callers that reach a
/// rename without passing the gate. Best-effort: a failure is logged, not
/// surfaced, since the
/// rename itself has already succeeded. See #1927 follow-up and #2596.
pub fn discard_sandbox_container_after_move(session_id: &str, is_sandboxed: bool) {
if !is_sandboxed {
return;
}
let container = DockerContainer::from_session_id(session_id);
match container.discard() {
Teardown::Removed => tracing::info!(
target: "containers.runtime",
session = %session_id,
"removed stale sandbox container after worktree move; it will be recreated with the new path on next start"
),
Teardown::AlreadyGone => {}
Teardown::Failed(e) => tracing::warn!(
target: "containers.runtime",
session = %session_id,
"failed to remove stale sandbox container after worktree move: {e}"
),
}
}
/// Stop a sandbox session's container without removing it, so it can be
/// restarted on re-attach.
///
/// No-op for non-sandbox sessions (`is_sandboxed` is taken so they skip the
/// `docker inspect`/`docker stop` subprocesses entirely). A `Probe::Running`
/// container is stopped and a stop failure is propagated; a transient
/// `docker inspect` failure ([`Probe::Unknown`]) still attempts the stop with a
/// `warn!`, so a possibly-live container is not silently abandoned, and a
/// second best-effort `warn!` covers a stop that then also fails.
///
/// Shared by [`Instance::stop`](crate::session::Instance::stop) and the trash
/// path (`trash_session_by_id` via
/// [`prepare_trashed_worktree`](crate::session::trash::prepare_trashed_worktree)):
/// a trashed session is durably stopped, and its container must come down for
/// the same reason `stop` brings it down. The container runs `sleep infinity`
/// for the life of the session and bind-mounts the worktree dir, so leaving it
/// up both leaks a running container for the whole retention window and makes
/// [`relocate_worktree_to_trash`](crate::session::trash::relocate_worktree_to_trash)
/// fail `EBUSY` against the live mount.
///
/// BLOCKING: `docker stop` waits out the container's stop grace period (~10s,
/// because the PID-1 `sleep infinity` ignores SIGTERM until the SIGKILL), so
/// this must never run on the TUI input thread. Every UI-facing caller runs it
/// off-thread: the stop path via [`perform_stop`](crate::session::stop::perform_stop)
/// on the `StopPoller`, the trash path via
/// [`perform_trash`](crate::session::trash::perform_trash) on the `TrashPoller`,
/// and the server via `spawn_blocking`. The CLI runs it inline only because it
/// is a one-shot process with no event loop to starve.
pub fn stop_sandbox_container(session_id: &str, is_sandboxed: bool) -> anyhow::Result<()> {
if !is_sandboxed {
return Ok(());
}
let container = DockerContainer::from_session_id(session_id);
match container.probe_running() {
Probe::Running => container.stop()?,
Probe::NotRunning => {}
Probe::Unknown(e) => {
tracing::warn!(
target: "containers.runtime",
session = %session_id,
error = %e,
"docker inspect failed while probing sandbox container before stop; attempting stop anyway to avoid leaving a possibly-live container behind"
);
if let Err(stop_err) = container.stop() {
tracing::warn!(
target: "containers.runtime",
session = %session_id,
error = %stop_err,
"sandbox container stop failed after probe failure; container may already be gone or docker is unreachable"
);
}
}
}
Ok(())
}
/// Inputs for an in-place worktree workdir edit.
pub struct WorktreeEditRequest<'a> {
/// The session's current worktree metadata.
@@ -554,37 +351,6 @@ mod tests {
}
}
#[test]
fn holds_worktree_short_circuits_without_sandbox() {
// The gate that guards `set_worktree_name_for_selected` and
// `rename_selected` (#2117, #2414): a non-sandbox session must return
// false before touching the container runtime, so a plain worktree
// rename never pays a `docker inspect`. The live-container branch is the
// same helper the tied-rename path already relies on.
assert!(!ensure_sandbox_container_released("any-session-id", false));
}
#[test]
fn discard_after_move_short_circuits_without_sandbox() {
// After the #2596 fix, the `is_sandboxed` guard is the ONLY thing that
// keeps a plain (non-sandbox) worktree rename from spawning a `docker`
// subprocess. Time-bound to catch a future edit that reorders the
// guard below the runtime call: the sandbox-off path returns before
// any `DockerContainer::from_session_id` allocation, so wall time is
// sub-millisecond; 100 ms is a CI-safe upper bound that still catches
// a real `docker inspect` (dozens to hundreds of ms) or a
// `container.discard()` shell-out. The Teardown-variant branches
// (Removed / AlreadyGone / Failed) are covered by the
// `classify_removal` unit tests in `containers::mod`.
let start = std::time::Instant::now();
discard_sandbox_container_after_move("any-session-id", false);
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_millis(100),
"non-sandbox path must short-circuit before any container runtime call; elapsed = {elapsed:?}"
);
}
#[test]
fn leaf_from_title_slugifies() {
assert_eq!(worktree_leaf_from_title("Auth refactor"), "auth-refactor");
+28 -18
View File
@@ -9,7 +9,6 @@ pub(crate) mod status_rules;
mod terminal_session;
#[cfg(test)]
pub(crate) mod test_helpers;
mod tool_session;
pub(crate) mod utils;
#[cfg(unix)]
pub(crate) mod vt;
@@ -22,8 +21,7 @@ pub(crate) use status_detection::{
reconcile_waiting_hook,
};
pub use status_detection::{detect_status_from_content, detect_status_from_content_in};
pub use terminal_session::{kill_all_terminals_for_id, ContainerTerminalSession, TerminalSession};
pub use tool_session::{kill_all_tool_sessions_for_id, ToolSession};
pub use terminal_session::{kill_all_terminals_for_id, TerminalSession};
pub use utils::tmux_prefix_display;
#[cfg(any(test, feature = "test-support"))]
@@ -224,12 +222,18 @@ pub const TERMINAL_PREFIX: &str = if cfg!(debug_assertions) {
} else {
"aoe_term_"
};
pub const CONTAINER_TERMINAL_PREFIX: &str = if cfg!(debug_assertions) {
/// Legacy prefix from the removed container-terminal feature. Retained only
/// so `aoe_cterm_*` sessions left behind by an older build are still swept by
/// teardown and never mistaken for an agent pane; nothing creates these.
pub const LEGACY_CONTAINER_TERMINAL_PREFIX: &str = if cfg!(debug_assertions) {
"aoe_dev_cterm_"
} else {
"aoe_cterm_"
};
pub const TOOL_PREFIX: &str = if cfg!(debug_assertions) {
/// Legacy prefix from the removed tool-session feature. Retained only so
/// `aoe_tool_*` sessions left behind by an older build are never mistaken
/// for an agent pane; nothing creates these any more.
pub const LEGACY_TOOL_PREFIX: &str = if cfg!(debug_assertions) {
"aoe_dev_tool_"
} else {
"aoe_tool_"
@@ -363,7 +367,11 @@ fn id_suffix(session_id: &str) -> String {
/// Auxiliary kinds whose prefixes nest under `SESSION_PREFIX`, so the agent
/// shape has to exclude them explicitly.
const AGENT_EXCLUDED_PREFIXES: &[&str] = &[TERMINAL_PREFIX, CONTAINER_TERMINAL_PREFIX, TOOL_PREFIX];
const AGENT_EXCLUDED_PREFIXES: &[&str] = &[
TERMINAL_PREFIX,
LEGACY_CONTAINER_TERMINAL_PREFIX,
LEGACY_TOOL_PREFIX,
];
/// How one kind of aoe tmux session's name is shaped for one session id, so a
/// live session can still be found after the title embedded in the name has
@@ -403,11 +411,11 @@ impl NameShape<'_> {
}
}
/// The container-terminal shape for a session id. `CONTAINER_TERMINAL_PREFIX`
/// The container-terminal shape for a session id. `LEGACY_CONTAINER_TERMINAL_PREFIX`
/// does not nest under any other kind's prefix, so nothing is excluded.
pub(crate) fn container<'a>(suffix: &'a str) -> NameShape<'a> {
NameShape {
prefix: CONTAINER_TERMINAL_PREFIX,
prefix: LEGACY_CONTAINER_TERMINAL_PREFIX,
suffix,
excluded_prefixes: &[],
}
@@ -591,8 +599,8 @@ fn session_name_from_cache(derived: &str, shape: &NameShape) -> Option<String> {
}
/// Force-stop every aoe-owned tmux session (agent, terminal, container
/// terminal, tool) in this namespace. Mirrors `kill_all_tool_sessions_for_id`
/// but sweeps the whole `SESSION_PREFIX` namespace. Returns the number of
/// terminal) in this namespace: it sweeps the whole `SESSION_PREFIX`
/// namespace, so legacy tool sessions are cleaned up too. Returns the number of
/// sessions killed. Refreshes the session cache once at the end.
///
/// `Err` means the `tmux list-sessions` process could not be spawned (e.g.
@@ -1326,11 +1334,11 @@ mod tests {
fn resolve_agent_session_name_ignores_other_kinds_and_other_ids() {
let derived = format!("{P}Refactor_{ID8}");
let names = [
// Same id, but the paired terminal / container terminal / tool
// Same id, but the paired terminal / container terminal / legacy tool
// sub-sessions are not the agent pane.
format!("{TERMINAL_PREFIX}Vikings_{ID8}"),
format!("{CONTAINER_TERMINAL_PREFIX}Vikings_{ID8}"),
format!("{TOOL_PREFIX}lazygit_Vikings_{ID8}"),
format!("{LEGACY_CONTAINER_TERMINAL_PREFIX}Vikings_{ID8}"),
format!("{LEGACY_TOOL_PREFIX}lazygit_Vikings_{ID8}"),
// Agent-shaped, but a different session's id.
format!("{P}Vikings_99999999"),
// Not ours at all.
@@ -1440,7 +1448,7 @@ mod tests {
// under test is the kind-preference, not liveness.
let agent = format!("{P}Refactor_{ID8}");
let terminal = format!("{TERMINAL_PREFIX}Refactor_{ID8}");
let container = format!("{CONTAINER_TERMINAL_PREFIX}Refactor_{ID8}");
let container = format!("{LEGACY_CONTAINER_TERMINAL_PREFIX}Refactor_{ID8}");
let all = [agent.as_str(), terminal.as_str(), container.as_str()];
assert_eq!(
@@ -1461,9 +1469,9 @@ mod tests {
#[test]
#[serial_test::serial]
fn live_any_kind_name_for_id_excludes_tool_subsessions_and_other_ids() {
fn live_any_kind_name_for_id_excludes_legacy_tool_subsessions_and_other_ids() {
let names = [
format!("{TOOL_PREFIX}lazygit_Refactor_{ID8}"),
format!("{LEGACY_TOOL_PREFIX}lazygit_Refactor_{ID8}"),
format!("{P}Refactor_99999999"),
format!("{TERMINAL_PREFIX}Refactor_99999999"),
"vim".to_string(),
@@ -1526,8 +1534,10 @@ mod tests {
fn is_aoe_session_matches_every_kind_and_rejects_foreign() {
assert!(is_aoe_session(&format!("{P}my_proj_abc12345")));
assert!(is_aoe_session(&format!("{TERMINAL_PREFIX}x")));
assert!(is_aoe_session(&format!("{CONTAINER_TERMINAL_PREFIX}x")));
assert!(is_aoe_session(&format!("{TOOL_PREFIX}x")));
assert!(is_aoe_session(&format!(
"{LEGACY_CONTAINER_TERMINAL_PREFIX}x"
)));
assert!(is_aoe_session(&format!("{LEGACY_TOOL_PREFIX}x")));
assert!(!is_aoe_session("vim"));
assert!(!is_aoe_session("my_aoe_session"));
}
+4 -254
View File
@@ -385,29 +385,7 @@ impl Session {
profile: &str,
extra_env: &[PaneEnvMutation],
) -> Result<()> {
self.create_with_size_env_inner(working_dir, command, size, profile, extra_env, &[])
}
/// Create a pane whose container runtime reads target environment values
/// from an inherited env-file descriptor. The target keys never enter the
/// host pane environment.
pub(crate) fn create_with_size_env_and_container_env(
&self,
working_dir: &str,
command: Option<&str>,
size: Option<(u16, u16)>,
profile: &str,
extra_env: &[PaneEnvMutation],
container_env: &[(String, String)],
) -> Result<()> {
self.create_with_size_env_inner(
working_dir,
command,
size,
profile,
extra_env,
container_env,
)
self.create_with_size_env_inner(working_dir, command, size, profile, extra_env)
}
fn create_with_size_env_inner(
@@ -417,7 +395,6 @@ impl Session {
size: Option<(u16, u16)>,
profile: &str,
extra_env: &[PaneEnvMutation],
container_env: &[(String, String)],
) -> Result<()> {
if self.exists() {
return Ok(());
@@ -480,7 +457,7 @@ impl Session {
protected_env.push(mutation.clone());
}
let mut env_file = EphemeralEnvFile::create(&protected_env, container_env)?;
let mut env_file = EphemeralEnvFile::create(&protected_env)?;
let wrapped_command = env_file.wrap_command(command)?;
let mut args = build_create_args(
&self.name,
@@ -1810,42 +1787,11 @@ fn raw_byte_batches(bytes: &[u8]) -> Vec<Vec<String>> {
/// A successful tmux create alone does not transfer cleanup ownership.
struct EphemeralEnvFile {
path: Option<std::path::PathBuf>,
container_env_path: Option<std::path::PathBuf>,
}
impl EphemeralEnvFile {
fn create(env: &[PaneEnvMutation], container_env: &[(String, String)]) -> Result<Self> {
let mut channel = Self {
path: None,
container_env_path: None,
};
if !container_env.is_empty() {
let mut file = tempfile::Builder::new()
.prefix(PANE_ENV_FILE_PREFIX)
.tempfile()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
for (key, value) in container_env {
anyhow::ensure!(
crate::session::environment::is_valid_env_key(key),
"invalid container environment key {key:?}"
);
anyhow::ensure!(
!value
.bytes()
.any(|byte| matches!(byte, b'\0' | b'\n' | b'\r')),
"container environment value for {key} cannot be represented in an env-file"
);
writeln!(file, "{key}={value}")?;
}
file.flush()?;
let (_handle, path) = file.keep().map_err(|error| error.error)?;
channel.container_env_path = Some(path);
}
fn create(env: &[PaneEnvMutation]) -> Result<Self> {
let mut channel = Self { path: None };
let mut file = tempfile::Builder::new()
.prefix(PANE_ENV_FILE_PREFIX)
@@ -1887,19 +1833,6 @@ impl EphemeralEnvFile {
});
let shell = crate::session::environment::user_posix_shell();
let mut file = std::fs::OpenOptions::new().append(true).open(path)?;
if let Some(container_env_path) = self.container_env_path.as_deref() {
writeln!(
file,
"exec {}<{} || exit 1",
crate::session::environment::CONTAINER_EXEC_ENV_FD,
script_shell_escape(&container_env_path.to_string_lossy())
)?;
writeln!(
file,
"rm -f -- {}",
script_shell_escape(&container_env_path.to_string_lossy())
)?;
}
writeln!(
file,
"rm -f -- {}",
@@ -1936,7 +1869,6 @@ impl EphemeralEnvFile {
fn disarm(&mut self) {
self.path = None;
self.container_env_path = None;
}
}
@@ -1945,9 +1877,6 @@ impl Drop for EphemeralEnvFile {
if let Some(path) = self.path.take() {
let _ = std::fs::remove_file(path);
}
if let Some(path) = self.container_env_path.take() {
let _ = std::fs::remove_file(path);
}
}
}
@@ -3922,185 +3851,6 @@ mod tests {
assert_eq!(args.last().unwrap(), "claude");
}
#[test]
fn test_protected_env_file_keeps_secret_out_of_pane_argv_and_rejects_invalid_keys() {
let secret = "literal-secret-value";
let file = EphemeralEnvFile::create(
&[
PaneEnvMutation::set("GOOD_TOKEN".to_string(), "stale-profile-value".to_string()),
PaneEnvMutation::set("GOOD_TOKEN".to_string(), secret.to_string()),
PaneEnvMutation::set("X; touch /tmp/injected; #".to_string(), "bad".to_string()),
],
&[],
)
.unwrap();
let path = file.path.as_ref().unwrap().clone();
let wrapper = file.wrap_command(Some("omp --help")).unwrap();
let args = build_create_args("s", "/tmp/work", &[], Some(&wrapper), None);
assert!(wrapper.starts_with(&format!(
"exec {} ",
crate::session::environment::shell_escape(
&crate::session::environment::user_posix_shell()
)
)));
assert!(!wrapper.contains(secret));
assert!(!wrapper.contains("stale-profile-value"));
assert!(!args.iter().any(|arg| arg.contains(secret)));
assert!(!wrapper.contains("touch /tmp/injected"));
assert!(wrapper.contains(&path.to_string_lossy().to_string()));
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.find("rm -f").unwrap() < contents.find("omp --help").unwrap());
assert!(contents.contains("export GOOD_TOKEN='literal-secret-value'"));
let stale = contents
.find("export GOOD_TOKEN='stale-profile-value'")
.unwrap();
let minted = contents
.find("export GOOD_TOKEN='literal-secret-value'")
.unwrap();
assert!(stale < minted, "later minted export must win when sourced");
assert!(!contents.contains("touch /tmp/injected"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}
drop(file);
assert!(!path.exists(), "failure guard must clean up the channel");
}
#[test]
fn test_protected_env_file_preserves_multiline_values() {
let temp = tempfile::tempdir().unwrap();
let output = temp.path().join("multiline");
let value = "line one\nline two\r\nquote ' intact";
let stale_output = temp.path().join("unset");
let mut file = EphemeralEnvFile::create(
&[
PaneEnvMutation::set("MULTILINE_SECRET".to_string(), value.to_string()),
PaneEnvMutation::unset("AOE_TEST_STALE".to_string()),
],
&[],
)
.unwrap();
let command = format!(
"printf '%s' \"$MULTILINE_SECRET\" > {}; printf '%s' \"${{AOE_TEST_STALE+x}}\" > {}",
script_shell_escape(&output.to_string_lossy()),
script_shell_escape(&stale_output.to_string_lossy())
);
let wrapper = file.wrap_command(Some(&command)).unwrap();
let status = std::process::Command::new("sh")
.args(["-c", &wrapper])
.env("AOE_TEST_STALE", "inherited")
.status()
.unwrap();
assert!(status.success());
assert_eq!(std::fs::read(&output).unwrap(), value.as_bytes());
assert_eq!(std::fs::read_to_string(stale_output).unwrap(), "");
assert!(file.wait_until_consumed(Duration::ZERO));
file.disarm();
}
/// `#[serial]` because the assertion reads the inherited PATH, and every
/// test that scrubs PATH process-globally carries that same default-key
/// annotation: `crate::acp::node`, `crate::acp::acp_client`, and
/// `crate::update::install`. Not an `EnvGuard` lock: none of them takes
/// `test_support::ENV_LOCK`.
#[test]
#[serial_test::serial]
fn test_container_env_file_does_not_mutate_host_process_environment() {
let temp = tempfile::tempdir().unwrap();
let host_output = temp.path().join("host-env");
let payload_output = temp.path().join("container-env");
let target_env = vec![
("PATH".to_string(), "/repo-controlled/bin".to_string()),
(
"DOCKER_HOST".to_string(),
"tcp://repo-controlled.example".to_string(),
),
("TOKEN".to_string(), "secret-value".to_string()),
];
let mut file = EphemeralEnvFile::create(&[], &target_env).unwrap();
let script_path = file.path.as_ref().unwrap().clone();
let payload_path = file.container_env_path.as_ref().unwrap().clone();
let command = format!(
"printf '%s\\n%s' \"$PATH\" \"${{DOCKER_HOST-unset}}\" > {}; \
cat {} > {}",
script_shell_escape(&host_output.to_string_lossy()),
crate::session::environment::CONTAINER_EXEC_ENV_PATH,
script_shell_escape(&payload_output.to_string_lossy()),
);
let wrapper = file.wrap_command(Some(&command)).unwrap();
let script = std::fs::read_to_string(&script_path).unwrap();
assert!(!script.contains("/repo-controlled/bin"));
assert!(!script.contains("tcp://repo-controlled.example"));
assert!(!wrapper.contains("secret-value"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(&payload_path)
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
// The PATH is asserted back below to prove the env file did not mutate
// the host process environment, so it has to be a value this host can
// actually resolve `cat` on.
// `var_os`, not `var`: a PATH entry need not be UTF-8, and `var`
// returns `Err(VarError::NotUnicode)` for one that isn't, which the
// `unwrap_or_default` below would turn into an empty PATH rather than
// a failure.
let host_path = std::env::var_os("PATH").unwrap_or_default();
let status = std::process::Command::new("/bin/sh")
.args(["-c", &wrapper])
.env("PATH", &host_path)
.env_remove("DOCKER_HOST")
.env_remove("BASH_ENV")
.env_remove("ENV")
.status()
.unwrap();
assert!(status.success());
// Compared as bytes for the same reason: a non-UTF-8 PATH survives the
// round trip through the shell but not through `read_to_string`.
let mut expected = host_path.as_encoded_bytes().to_vec();
expected.extend_from_slice(b"\nunset");
// Rendered lossily in the message only: `assert_eq!` on `Vec<u8>`
// prints decimal byte arrays, which is unreadable for a whole PATH.
let actual = std::fs::read(host_output).unwrap();
assert_eq!(
actual,
expected,
"env file mutated the host environment: {:?} != {:?}",
String::from_utf8_lossy(&actual),
String::from_utf8_lossy(&expected),
);
assert_eq!(
std::fs::read_to_string(payload_output).unwrap(),
"PATH=/repo-controlled/bin\n\
DOCKER_HOST=tcp://repo-controlled.example\n\
TOKEN=secret-value\n"
);
assert!(file.wait_until_consumed(Duration::ZERO));
assert!(!payload_path.exists());
file.disarm();
assert!(EphemeralEnvFile::create(
&[],
&[("MULTILINE".to_string(), "line one\nline two".to_string())],
)
.is_err());
}
#[test]
#[serial_test::serial]
fn test_protected_env_is_consumed_before_create_returns() {
+5 -32
View File
@@ -5,11 +5,6 @@ use ratatui::style::Color;
use crate::tui::styles::Theme;
/// Information about a sandboxed session for status bar display.
pub struct SandboxDisplay {
pub container_name: String,
}
/// Convert a ratatui Color to a tmux-compatible hex color string (e.g. "#0f172a").
fn color_to_tmux(color: Color) -> String {
match color {
@@ -20,13 +15,12 @@ fn color_to_tmux(color: Color) -> String {
/// Apply aoe-styled status bar configuration to a tmux session.
///
/// Sets tmux user options (@aoe_title, @aoe_branch, @aoe_sandbox) and configures
/// Sets tmux user options (@aoe_title, @aoe_branch) and configures
/// the status-right to display session information using theme colors.
pub fn apply_status_bar(
session_name: &str,
title: &str,
branch: Option<&str>,
sandbox: Option<&SandboxDisplay>,
theme: &Theme,
) -> Result<()> {
// Re-enable the status line explicitly: a web attach turns it off
@@ -42,24 +36,17 @@ pub fn apply_status_bar(
set_session_option(session_name, "@aoe_branch", branch_name)?;
}
// Set sandbox info if running in docker container
if let Some(sandbox_info) = sandbox {
set_session_option(session_name, "@aoe_sandbox", &sandbox_info.container_name)?;
}
let accent = color_to_tmux(theme.accent);
let fg = color_to_tmux(theme.text);
let bg = color_to_tmux(theme.background);
let branch_color = color_to_tmux(theme.branch);
let sandbox_color = color_to_tmux(theme.sandbox);
let hint = color_to_tmux(theme.dimmed);
// Format: "aoe: Title | branch | [container] | 14:30"
// Format: "aoe: Title | branch | 14:30"
let status_format = format!(
" #[fg={accent},bold]aoe#[fg={fg},nobold]: \
#{{@aoe_title}}\
#{{?#{{@aoe_branch}}, #[fg={branch_color}]| #{{@aoe_branch}}#[fg={fg}],}}\
#{{?#{{@aoe_sandbox}}, #[fg={sandbox_color}]\u{2b21} #{{@aoe_sandbox}}#[fg={fg}],}}\
| %H:%M ",
);
@@ -126,7 +113,6 @@ pub fn apply_all_tmux_options(
session_name: &str,
title: &str,
branch: Option<&str>,
sandbox: Option<&SandboxDisplay>,
profile: &str,
) {
use crate::session::config::{resolve_tmux_setting, TmuxSetting, TmuxSettingAction};
@@ -143,7 +129,7 @@ pub fn apply_all_tmux_options(
// Palette mode only affects the TUI's direct terminal output.
let theme = load_theme(&theme_name);
if let Err(e) = apply_status_bar(session_name, title, branch, sandbox, &theme) {
if let Err(e) = apply_status_bar(session_name, title, branch, &theme) {
tracing::debug!(target: "tmux.status", "Failed to apply tmux status bar: {}", e);
}
} else {
@@ -188,7 +174,6 @@ pub fn apply_all_tmux_options(
pub struct SessionInfo {
pub title: String,
pub branch: Option<String>,
pub sandbox: Option<String>,
}
/// Get session info for the current tmux session (used by `aoe tmux-status` command).
@@ -216,17 +201,12 @@ pub fn get_session_info_for_current() -> Option<SessionInfo> {
});
let branch = get_session_option(&session_name, "@aoe_branch");
let sandbox = get_session_option(&session_name, "@aoe_sandbox");
Some(SessionInfo {
title,
branch,
sandbox,
})
Some(SessionInfo { title, branch })
}
/// Get formatted status string for the current tmux session.
/// Returns a plain text string like `aoe: Title | branch | [container]`
/// Returns a plain text string like `aoe: Title | branch`
pub fn get_status_for_current_session() -> Option<String> {
let info = get_session_info_for_current()?;
@@ -237,12 +217,6 @@ pub fn get_status_for_current_session() -> Option<String> {
result.push_str(b);
}
if let Some(s) = &info.sandbox {
result.push_str(" [");
result.push_str(s);
result.push(']');
}
Some(result)
}
@@ -296,7 +270,6 @@ mod tests {
("text", color_to_tmux(theme.text)),
("accent", color_to_tmux(theme.accent)),
("branch", color_to_tmux(theme.branch)),
("sandbox", color_to_tmux(theme.sandbox)),
("dimmed", color_to_tmux(theme.dimmed)),
];
for (field, hex) in &colors {
+5 -187
View File
@@ -1,4 +1,4 @@
//! Paired terminal sessions — host (`TerminalSession`) and sandbox (`ContainerTerminalSession`).
//! Paired host terminal sessions (`TerminalSession`).
//!
//! The two session types have nearly identical lifecycles, so the
//! implementation lives in [`PairedTerminal`] and the public types are thin
@@ -10,7 +10,7 @@ use super::utils::{
append_default_shell_args, append_pane_base_index_args, append_remain_on_exit_args,
append_tmux_setting_args, append_window_size_args, is_pane_dead, sanitize_session_name,
};
use super::{refresh_session_cache, CONTAINER_TERMINAL_PREFIX, TERMINAL_PREFIX};
use super::{refresh_session_cache, LEGACY_CONTAINER_TERMINAL_PREFIX, TERMINAL_PREFIX};
use crate::cli::truncate_id;
use crate::process;
use crate::session::environment::{login_shell_command, user_shell};
@@ -20,21 +20,18 @@ use crate::session::environment::{login_shell_command, user_shell};
#[derive(Debug, Clone, Copy)]
enum TerminalKind {
Host,
Container,
}
impl TerminalKind {
fn prefix(self) -> &'static str {
match self {
TerminalKind::Host => TERMINAL_PREFIX,
TerminalKind::Container => CONTAINER_TERMINAL_PREFIX,
}
}
fn label(self) -> &'static str {
match self {
TerminalKind::Host => "terminal session",
TerminalKind::Container => "container terminal session",
}
}
}
@@ -94,7 +91,7 @@ fn absolute_shell_in(shell: &str, paths: Option<&std::ffi::OsStr>) -> Option<Str
}
/// Shared implementation of the paired-terminal lifecycle. Not exposed; the
/// public [`TerminalSession`] and [`ContainerTerminalSession`] wrap one of
/// the public [`TerminalSession`] wraps one of
/// these with a fixed [`TerminalKind`].
struct PairedTerminal {
name: String,
@@ -390,92 +387,11 @@ impl TerminalSession {
}
}
/// Container terminal session for sandboxed sessions.
/// Uses a separate prefix (aoe_cterm_) to allow both container and host terminals to coexist.
pub struct ContainerTerminalSession {
inner: PairedTerminal,
}
impl ContainerTerminalSession {
pub fn new(id: &str, title: &str) -> Result<Self> {
Self::new_indexed(id, title, 0)
}
pub fn new_indexed(id: &str, title: &str, index: u32) -> Result<Self> {
Ok(Self {
inner: PairedTerminal::new(TerminalKind::Container, id, title, index),
})
}
/// The name of the paired terminal to ACT on: the title-derived name
/// normally, or the live terminal carrying this session id's tail when the
/// stored title has moved out from under it. Callers wanting the session's
/// CURRENT name want this; [`Self::generate_name`] stays a pure derivation
/// for computing the name to rename TO.
pub fn resolve_name(id: &str, title: &str) -> String {
Self::resolve_name_indexed(id, title, 0)
}
/// [`Self::resolve_name`] for the web dashboard's additional terminal tabs.
pub fn resolve_name_indexed(id: &str, title: &str, index: u32) -> String {
PairedTerminal::resolve_name(TerminalKind::Container, id, title, index)
}
pub fn generate_name(id: &str, title: &str) -> String {
Self::generate_name_indexed(id, title, 0)
}
pub fn generate_name_indexed(id: &str, title: &str, index: u32) -> String {
PairedTerminal::generate_name(TerminalKind::Container, id, title, index)
}
pub fn name(&self) -> &str {
&self.inner.name
}
pub fn exists(&self) -> bool {
self.inner.exists()
}
pub fn is_pane_dead(&self) -> bool {
self.inner.is_pane_dead()
}
pub fn create_with_size(
&self,
working_dir: &str,
command: Option<&str>,
size: Option<(u16, u16)>,
profile: &str,
) -> Result<()> {
self.inner
.create_with_size(working_dir, command, size, profile)
}
pub fn kill(&self) -> Result<()> {
self.inner.kill()
}
pub fn get_pane_pid(&self) -> Option<u32> {
self.inner.get_pane_pid()
}
pub fn attach(&self) -> Result<()> {
self.inner.attach()
}
/// Preview capture with the window's other panes composited in; see
/// [`super::Session::capture_window_composited`].
pub fn capture_window_composited(&self, lines: usize) -> Result<String> {
self.inner.capture_window_composited(lines)
}
}
/// Kill every paired terminal tmux session (host and container, any index)
/// belonging to `id`. The single-index `kill` methods only target one
/// deterministic name; this scans the live session list so the multi-terminal
/// web tabs (`_t{N}` suffixes) and any title-change orphans are all reaped on
/// session teardown. Mirrors [`crate::tmux::kill_all_tool_sessions_for_id`].
/// session teardown.
pub fn kill_all_terminals_for_id(id: &str) {
let needle = format!("_{}", truncate_id(id, 8));
@@ -488,7 +404,7 @@ pub fn kill_all_terminals_for_id(id: &str) {
let stdout = String::from_utf8_lossy(&out.stdout);
for line in stdout.lines() {
if !line.starts_with(TERMINAL_PREFIX)
&& !line.starts_with(CONTAINER_TERMINAL_PREFIX)
&& !line.starts_with(LEGACY_CONTAINER_TERMINAL_PREFIX)
{
continue;
}
@@ -528,14 +444,6 @@ mod tests {
assert!(name.contains("abc123de"));
}
#[test]
fn test_container_terminal_session_generate_name() {
let name = ContainerTerminalSession::generate_name("abc123def456", "My Project");
assert!(name.starts_with(CONTAINER_TERMINAL_PREFIX));
assert!(name.contains("My_Project"));
assert!(name.contains("abc123de"));
}
/// A session id long enough that `truncate_id(.., 8)` truncates, so the
/// tests exercise the real `_<id8>` tail.
const ID: &str = "abc12345deadbeef";
@@ -595,32 +503,6 @@ mod tests {
);
}
#[test]
#[serial_test::serial]
fn resolve_name_keeps_host_and_container_terminals_apart() {
// Only the container terminal is live. The host terminal must NOT adopt
// it: they are different panes with different shells.
let guard = crate::tmux::SessionCacheGuard::capture();
let container = ContainerTerminalSession::generate_name(ID, "Vikings");
guard.force_present(&[container.as_str()]);
assert_eq!(
ContainerTerminalSession::resolve_name(ID, "Refactor billing"),
container
);
assert_eq!(
ContainerTerminalSession::new(ID, "Refactor billing")
.expect("container terminal")
.name(),
container
);
assert_eq!(
TerminalSession::resolve_name(ID, "Refactor billing"),
TerminalSession::generate_name(ID, "Refactor billing"),
"a live container terminal is not the host terminal"
);
}
#[test]
fn test_terminal_session_name_differs_from_agent_session() {
let agent_name = Session::generate_name("abc123def456", "My Project");
@@ -630,20 +512,6 @@ mod tests {
assert!(terminal_name.starts_with(TERMINAL_PREFIX));
}
#[test]
fn test_terminal_index_zero_matches_legacy_name() {
// Index 0 must be byte-identical to the historical single-terminal
// name so existing tmux sessions, URLs, and the TUI keep working.
let legacy = TerminalSession::generate_name("abc123def456", "My Project");
let indexed_zero = TerminalSession::generate_name_indexed("abc123def456", "My Project", 0);
assert_eq!(legacy, indexed_zero);
let legacy_c = ContainerTerminalSession::generate_name("abc123def456", "My Project");
let indexed_zero_c =
ContainerTerminalSession::generate_name_indexed("abc123def456", "My Project", 0);
assert_eq!(legacy_c, indexed_zero_c);
}
#[test]
fn test_terminal_index_nonzero_suffixed_and_distinct() {
let zero = TerminalSession::generate_name_indexed("abc123def456", "My Project", 0);
@@ -656,15 +524,6 @@ mod tests {
assert!(one.starts_with(&zero));
}
#[test]
fn test_container_terminal_name_differs_from_host_terminal() {
let host_name = TerminalSession::generate_name("abc123def456", "My Project");
let container_name = ContainerTerminalSession::generate_name("abc123def456", "My Project");
assert_ne!(host_name, container_name);
assert!(host_name.starts_with(TERMINAL_PREFIX));
assert!(container_name.starts_with(CONTAINER_TERMINAL_PREFIX));
}
#[test]
fn test_host_pane_inputs_injects_env_and_login_shell() {
// Regression for #2608: a host terminal with no explicit command must
@@ -876,45 +735,4 @@ mod tests {
"a host terminal must carry the forwarded desktop/session env (#3075)"
);
}
/// Container terminals keep the container's own env; the host desktop env
/// (DISPLAY, XDG_*, SSH_AUTH_SOCK, ...) must NOT leak into them. Drive the
/// real `create_with_size` for a container terminal (a plain command, no
/// Docker) and assert the sentinel is absent, locking the `matches!(Host)`
/// exclusion so dropping the guard is caught (#3075).
#[test]
#[serial_test::serial]
fn test_container_terminal_excludes_desktop_env() {
if !tmux_available() {
eprintln!("Skipping test: tmux not available");
return;
}
let key = "XDG_AOE_TERM_ENV_TEST_3075_CTR";
let _env = crate::session::test_support::EnvGuard::set(&[(key, "must-not-leak")]);
let guard = TmuxTestSession::new("aoe_test_term_ctr_excl");
let session = PairedTerminal {
name: guard.name().to_string(),
kind: TerminalKind::Container,
};
let created = session.create_with_size("/tmp", Some("sleep 5"), Some((80, 24)), "default");
// `show-environment` exits non-zero and prints nothing to stdout for an
// unknown variable, so a forwarded var yields the `KEY=VALUE` line and
// an excluded one yields an empty string.
let shown = crate::tmux::tmux_command()
.args(["show-environment", "-t", guard.name(), key])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string());
created.expect("create container terminal");
assert_eq!(
shown.as_deref(),
Some(""),
"a container terminal must NOT inherit the host desktop/session env (#3075)"
);
}
}
-412
View File
@@ -1,412 +0,0 @@
//! Tool sessions: user-configured dev tools (lazygit, yazi, tig, etc.) that
//! run in persistent tmux sessions tied to an agent session's working directory.
use anyhow::{bail, Result};
use super::utils::{
append_pane_base_index_args, append_remain_on_exit_args, append_tmux_setting_args,
append_window_size_args, is_pane_dead, sanitize_session_name,
};
use super::{refresh_session_cache, TOOL_PREFIX};
use crate::cli::truncate_id;
use crate::process;
pub struct ToolSession {
name: String,
}
impl ToolSession {
/// The tool sub-session name to ACT on. Resolves onto the live sub-session
/// for this session id and tool when the stored title has moved out from
/// under its name, so reopening a tool after a retitle reattaches to the
/// running pane instead of spawning a second one beside it (the same defect
/// #3157 fixed for the agent pane).
///
/// Known limit, inherited from the name format rather than introduced here:
/// the tool/title boundary is not recoverable from the name, so tool `git`
/// with title `log_T` and tool `git_log` with title `T` produce the same
/// name. Resolving `git` can therefore see a `git_log` pane as a candidate.
/// When both tools' panes are live the ambiguity guard in
/// `crate::tmux::resolve_session_name` keeps the derived name, so the only
/// exposure is a retitled session where the extension-named tool's pane is
/// live and the shorter one's is not. Resolution is skipped entirely rather
/// than guessing whenever more than one candidate matches.
pub fn new(session_id: &str, session_title: &str, tool_name: &str) -> Self {
// The tool name sits in the prefix, so it discriminates between a
// session's several tool sub-sessions without reference to the title.
let prefix = Self::name_prefix(tool_name);
let suffix = format!("_{}", truncate_id(session_id, 8));
let name = crate::tmux::live_session_name(
&Self::generate_name(session_id, session_title, tool_name),
&crate::tmux::NameShape {
prefix: &prefix,
suffix: &suffix,
excluded_prefixes: &[],
},
);
Self { name }
}
/// Purely derive the sub-session name, with no reference to what is live.
/// Callers wanting the session's CURRENT name want [`Self::new`].
pub fn generate_name(session_id: &str, session_title: &str, tool_name: &str) -> String {
format!(
"{}{}_{}",
Self::name_prefix(tool_name),
sanitize_session_name(session_title),
truncate_id(session_id, 8)
)
}
/// `aoe_tool_<tool>_`: everything before the (movable) title.
fn name_prefix(tool_name: &str) -> String {
format!("{TOOL_PREFIX}{}_", sanitize_session_name(tool_name))
}
pub fn session_name(&self) -> &str {
&self.name
}
pub fn exists(&self) -> bool {
crate::tmux::session_exists(&self.name)
}
pub fn is_pane_dead(&self) -> bool {
is_pane_dead(&self.name)
}
pub fn create_with_size(
&self,
working_dir: &str,
command: &str,
size: Option<(u16, u16)>,
profile: &str,
) -> Result<()> {
if self.exists() {
return Ok(());
}
let config = crate::tmux::tmux_option_config(profile);
let mut args = vec![
"new-session".to_string(),
"-d".to_string(),
"-s".to_string(),
self.name.clone(),
"-c".to_string(),
working_dir.to_string(),
];
if let Some((width, height)) = size {
args.push("-x".to_string());
args.push(width.to_string());
args.push("-y".to_string());
args.push(height.to_string());
}
args.push(command.to_string());
append_remain_on_exit_args(&mut args, &self.name);
append_pane_base_index_args(&mut args, &self.name);
append_window_size_args(&mut args, &self.name);
append_tmux_setting_args(&mut args, &self.name, &config);
let output = crate::tmux::tmux_command().args(&args).output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("duplicate session") {
refresh_session_cache();
return Ok(());
}
bail!("Failed to create tool session '{}': {}", self.name, stderr);
}
refresh_session_cache();
Ok(())
}
pub fn kill(&self) -> Result<()> {
if !self.exists() {
return Ok(());
}
if let Some(pane_pid) = self.get_pane_pid() {
process::kill_process_tree(pane_pid);
}
super::utils::kill_session_if_present(&self.name)?;
refresh_session_cache();
Ok(())
}
/// Poll the pane for up to ~200ms, checking every ~25ms, and error out
/// if it's still dead when the budget expires. A tool command that's
/// misconfigured (e.g. a usage error on an unrecognized flag) exits
/// near-instantly; without this check, `attach_tool_session` hands the
/// terminal to a dead, `remain-on-exit`-held pane and the user's only
/// way out is Ctrl+C, which (absent the SIGINT guard around the attach)
/// kills aoe itself rather than just the dead pane.
pub fn wait_until_ready(&self) -> Result<()> {
const BUDGET: std::time::Duration = std::time::Duration::from_millis(200);
const STEP: std::time::Duration = std::time::Duration::from_millis(25);
let deadline = std::time::Instant::now() + BUDGET;
loop {
if self.is_pane_dead() {
let tail = self.capture_pane(20).unwrap_or_default();
bail!(
"Tool session '{}' pane died before becoming ready:\n{}",
self.name,
tail
);
}
if std::time::Instant::now() >= deadline {
return Ok(());
}
std::thread::sleep(STEP);
}
}
pub fn attach(&self) -> Result<()> {
if !self.exists() {
bail!("Tool session does not exist: {}", self.name);
}
if std::env::var("TMUX").is_ok() {
let status = crate::tmux::tmux_command()
.args(["switch-client", "-t", &self.name])
.status()?;
if !status.success() {
let status = crate::tmux::tmux_command()
.args(["attach-session", "-t", &self.name])
.status()?;
if !status.success() {
bail!("Failed to attach to tool session '{}'", self.name);
}
}
} else {
let status = crate::tmux::tmux_command()
.args(["attach-session", "-t", &self.name])
.status()?;
if !status.success() {
bail!("Failed to attach to tool session '{}'", self.name);
}
}
Ok(())
}
pub fn capture_pane(&self, lines: usize) -> Result<String> {
super::Session::from_name(&self.name).capture_pane(lines)
}
/// Passive-preview capture with the window's other panes composited in;
/// see [`super::Session::capture_window_composited`].
pub fn capture_window_composited(&self, lines: usize) -> Result<String> {
super::Session::from_name(&self.name).capture_window_composited(lines)
}
fn get_pane_pid(&self) -> Option<u32> {
process::get_pane_pid(&self.name)
}
}
/// Kill all tool sessions associated with a given agent session ID.
/// Uses tmux list-sessions to find matches by ID suffix, so it works
/// even if tools have been removed from the config since creation.
pub fn kill_all_tool_sessions_for_id(session_id: &str) {
let id_suffix = format!("_{}", truncate_id(session_id, 8));
let output = crate::tmux::tmux_command()
.args(["list-sessions", "-F", "#{session_name}"])
.output();
if let Ok(out) = output {
if out.status.success() {
let stdout = String::from_utf8_lossy(&out.stdout);
for line in stdout.lines() {
if line.starts_with(TOOL_PREFIX) && line.ends_with(&id_suffix) {
if let Some(pid) = process::get_pane_pid(line) {
process::kill_process_tree(pid);
}
let _ = crate::tmux::tmux_command()
.args(["kill-session", "-t", line])
.output();
}
}
}
}
refresh_session_cache();
}
#[cfg(test)]
mod tests {
use super::super::test_helpers::TmuxTestSession;
use super::*;
/// A session id long enough that `truncate_id(.., 8)` truncates.
const ID: &str = "abc12345deadbeef";
#[test]
#[serial_test::serial]
fn new_adopts_a_retitled_tool_session_but_not_another_tools() {
// #3157 for tool sub-sessions: the title moved, the tool's tmux session
// kept the name it was created under. Reopening lazygit must reattach to
// the running pane rather than spawn a second one, and must never adopt
// a different tool's pane, which the tool name in the prefix guarantees.
let guard = crate::tmux::SessionCacheGuard::capture();
let stale_lazygit = ToolSession::generate_name(ID, "Vikings", "lazygit");
guard.force_present(&[stale_lazygit.as_str()]);
assert_eq!(
ToolSession::new(ID, "Refactor billing", "lazygit").session_name(),
stale_lazygit
);
// yazi was never opened, so it keeps the name it will be spawned under.
let yazi = ToolSession::new(ID, "Refactor billing", "yazi")
.session_name()
.to_string();
assert!(
yazi.starts_with(&format!("{TOOL_PREFIX}yazi_")),
"yazi must not adopt lazygit's pane: {yazi}"
);
assert!(yazi.contains("Refactor_billing"));
}
#[test]
#[serial_test::serial]
fn new_keeps_the_derived_name_when_an_extension_named_tool_is_ambiguous() {
// The tool/title boundary is not recoverable from the name: tool `git`
// with title `log_x` and tool `git_log` with title `x` collide. Guard
// the reachable half of that: when both panes are live, resolution must
// see two candidates and keep the derived name rather than pick one.
let guard = crate::tmux::SessionCacheGuard::capture();
let git = ToolSession::generate_name(ID, "Vikings", "git");
let git_log = ToolSession::generate_name(ID, "Vikings", "git_log");
assert!(
git_log.starts_with(&ToolSession::name_prefix("git")),
"the collision this guards only exists because `git_log` matches \
`git`'s prefix: {git_log}"
);
guard.force_present(&[git.as_str(), git_log.as_str()]);
let derived = ToolSession::generate_name(ID, "Refactor billing", "git");
assert_eq!(
ToolSession::new(ID, "Refactor billing", "git").session_name(),
derived,
"two candidates are ambiguous, so neither pane is adopted"
);
}
/// Helper: check if tmux is available for tests that need it
fn tmux_available() -> bool {
crate::tmux::tmux_command()
.arg("-V")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[test]
#[serial_test::serial]
fn wait_until_ready_errs_with_pane_tail_when_pane_dies_immediately() {
if !tmux_available() {
eprintln!("Skipping test: tmux not available");
return;
}
let dir = tempfile::tempdir().expect("tempdir");
let guard = TmuxTestSession::new("aoe_test_tool_dead");
let tool = ToolSession {
name: guard.name().to_string(),
};
tool.create_with_size(
dir.path().to_str().expect("utf8 path"),
"sh -c 'echo boom; exit 1'",
Some((80, 24)),
"default",
)
.expect("create_with_size");
let result = tool.wait_until_ready();
assert!(
result.is_err(),
"wait_until_ready should error when the pane dies before the budget expires"
);
let message = result.unwrap_err().to_string();
assert!(
message.contains("boom"),
"error should include the captured pane tail, got: {message:?}"
);
}
#[test]
#[serial_test::serial]
fn wait_until_ready_ok_when_pane_stays_alive() {
if !tmux_available() {
eprintln!("Skipping test: tmux not available");
return;
}
let dir = tempfile::tempdir().expect("tempdir");
let guard = TmuxTestSession::new("aoe_test_tool_alive");
let tool = ToolSession {
name: guard.name().to_string(),
};
tool.create_with_size(
dir.path().to_str().expect("utf8 path"),
"sleep 5",
Some((80, 24)),
"default",
)
.expect("create_with_size");
let result = tool.wait_until_ready();
assert!(
result.is_ok(),
"wait_until_ready should succeed for a still-running pane, got: {result:?}"
);
}
#[test]
fn new_name_includes_prefix_tool_title_and_truncated_id() {
let s = ToolSession::new("0123456789abcdef", "my-session", "lazygit");
let name = s.session_name();
assert!(name.starts_with(TOOL_PREFIX), "name was {}", name);
assert!(name.contains("lazygit"));
assert!(name.contains("my-session"));
assert!(name.ends_with("_01234567"), "name was {}", name);
}
#[test]
fn new_name_sanitizes_unsafe_characters() {
// tmux session names can't contain ':' or '.'
let s = ToolSession::new("abc12345", "feature/foo:bar", "my tool.v2");
let name = s.session_name();
assert!(!name.contains(':'), "name was {}", name);
assert!(!name.contains('.'), "name was {}", name);
assert!(!name.contains(' '), "name was {}", name);
}
#[test]
fn distinct_tools_on_same_session_have_distinct_names() {
let id = "0123456789abcdef";
let lazygit = ToolSession::new(id, "x", "lazygit");
let yazi = ToolSession::new(id, "x", "yazi");
assert_ne!(lazygit.session_name(), yazi.session_name());
}
#[test]
fn distinct_sessions_for_same_tool_have_distinct_names() {
let a = ToolSession::new("aaaaaaaa1111", "x", "lazygit");
let b = ToolSession::new("bbbbbbbb2222", "x", "lazygit");
assert_ne!(a.session_name(), b.session_name());
}
}
+1 -1
View File
@@ -131,7 +131,7 @@ pub fn append_default_shell_args(args: &mut Vec<String>, target: &str, shell: &s
/// Append every `[tmux]`-driven option write that a brand-new session needs, in
/// one call, so the three create paths (`session.rs`, `terminal_session.rs`,
/// `tool_session.rs`) cannot drift in which managed settings they honor.
/// cannot drift in which managed settings they honor.
///
/// Iterates the whole managed-settings table (`TmuxSetting::ALL`) and emits the
/// writes each resolved action declares, so a new managed option is one table
+16 -596
View File
@@ -1,6 +1,6 @@
//! Main TUI application
use anyhow::{Context, Result};
use anyhow::Result;
use crossterm::event::{
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, Event,
EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags,
@@ -8,15 +8,13 @@ use crossterm::event::{
};
use futures_util::StreamExt;
use ratatui::prelude::*;
use std::process::Stdio;
use std::time::Duration;
use super::attached_status_hooks::AttachedStatusHookWatcher;
use super::home::{HomeView, TerminalMode};
use super::home::HomeView;
use super::status_poller::StatusUpdate;
use super::styles::Theme;
use crate::containers::image_update::ImageUpdate;
use crate::session::{get_update_settings, update_app_state, Config};
use crate::session::Config;
use crate::tmux::AvailableTools;
/// Inter-key timeout for the paste-burst detector. After any printable Char
@@ -40,13 +38,6 @@ struct UpdateStatus {
}
impl UpdateStatus {
fn persistent(text: String) -> Self {
Self {
text,
expires_at: None,
}
}
fn transient(text: String) -> Self {
Self {
text,
@@ -78,18 +69,13 @@ pub struct App {
theme_palette_mode: bool,
needs_redraw: bool,
update_status: Option<UpdateStatus>,
/// A newer sandbox image detected in its registry, surfaced as the
/// lowest-priority bottom banner (below app-update and transient status).
/// `None` until the background check finds a drift the user hasn't snoozed.
image_update: Option<ImageUpdate>,
image_update_rx: Option<tokio::sync::oneshot::Receiver<anyhow::Result<Option<ImageUpdate>>>>,
/// In-flight `docker pull` of the sandbox image, kicked off when the user
/// accepts the banner's confirm. Result promotes into a transient toast.
image_pull_rx: Option<tokio::sync::oneshot::Receiver<anyhow::Result<()>>>,
/// Registry digest the user dismissed via Ctrl+x on the image banner.
/// Persisted to `app_state.dismissed_image_digest`; the banner stays hidden
/// while the registry still resolves to this digest.
dismissed_image_digest: Option<String>,
/// Held in an Option so `with_raw_mode_disabled` can drop it before
/// spawning child processes. Crossterm's EventStream runs a background
/// reader thread on stdin; if it's alive when tmux attach-session starts,
@@ -340,8 +326,6 @@ impl App {
home.show_no_agents();
}
let dismissed_image_digest = config.app_state.dismissed_image_digest.clone();
Ok(Self {
home,
should_quit: false,
@@ -350,10 +334,6 @@ impl App {
theme_palette_mode: palette_mode,
needs_redraw: true,
update_status: None,
image_update: None,
image_update_rx: None,
image_pull_rx: None,
dismissed_image_digest,
event_stream: Some(EventStream::new()),
// Initial state matches whatever `tui::run` did at startup: capture
// is requested by default, but Mosh suppresses the actual escape, so
@@ -621,14 +601,6 @@ impl App {
// Refresh tmux session cache
crate::tmux::refresh_session_cache();
// Check the sandbox image for a newer registry build, once at startup.
// Gated on the network-checks toggle, and only for users who actually
// run sandboxed sessions (so non-sandbox users never see a docker
// banner).
if get_update_settings().update_check_mode.is_enabled() && self.sandbox_in_use() {
self.spawn_image_update_check();
}
// SIGHUP/SIGTERM/SIGINT futures so we exit cleanly when the terminal
// emulator is force-quit, preventing PTY slot leaks (#541).
// These are polled directly inside tokio::select!, which guarantees
@@ -1553,20 +1525,6 @@ impl App {
// layout. Treat any banner state change as full-refresh
// work so the slow path rebuilds the layout AND the
// snapshot.
// The sandbox-image banner and its pull toast share the same
// bottom-row layout slot, so treat their changes as full-refresh
// work too.
if self.poll_image_update_check() {
self.needs_redraw = true;
refresh_needed = true;
needs_full_refresh = true;
}
if self.poll_image_pull_status() {
self.needs_redraw = true;
refresh_needed = true;
needs_full_refresh = true;
}
if last_status_refresh.elapsed() >= STATUS_REFRESH_INTERVAL {
self.home.request_status_refresh();
self.home.repair_session_id_pollers();
@@ -1677,8 +1635,8 @@ impl App {
//
// Config kick runs before the storage-mirror block:
// `refresh_from_config` invalidates profile-derived state that
// the block reads. Same `live_idle` gate; recomputing
// `tool_hotkey_cache` mid live-send disrupts input.
// the block reads, and reconfiguring mid live-send disrupts
// input, so the same `live_idle` gate applies.
let live_idle = self.home.live_send.is_none();
let config_kick = take_config_refresh_kick(live_idle, &self.home.config_watch.dirty);
if config_kick {
@@ -1889,24 +1847,12 @@ impl App {
self.update_status = None;
}
let status_text = self.update_status.as_ref().map(|s| s.text.as_str());
// Only hand the renderer the image banner when it's actually the active
// one; while a pull is in flight `image_banner_active` is false, so the
// banner can't re-render under the "pulling…" toast and clobber itself
// (#2072).
let image_update = self
.image_banner_active()
.then_some(self.image_update.as_ref());
// Reset before the render so a frame that skips the preview path
// (dialog open, non-home view) reads as zero capture/parse rather
// than leaking the previous frame's durations.
self.home.preview_timings = Default::default();
self.home.render(
frame,
frame.area(),
&self.theme,
status_text,
image_update.flatten(),
);
self.home
.render(frame, frame.area(), &self.theme, status_text);
// Sampled trace for frame-budget diagnostics. A full-frame trace on
// every paint would dominate the log at `default_level = trace`, so
// we only emit for (a) frames that break the 16ms / 60fps budget and
@@ -1934,148 +1880,6 @@ impl App {
);
}
}
/// Whether the user runs sandboxed sessions, so a docker-image banner is
/// worth surfacing. True when sandbox is on by default or any current
/// session is sandboxed; otherwise we skip the registry check entirely.
fn sandbox_in_use(&self) -> bool {
if Config::load_or_warn().sandbox.enabled_by_default {
return true;
}
self.home.instances().any(|i| i.is_sandboxed())
}
/// Is the sandbox-image banner the one currently shown? It sits below the
/// transient toast, so it only owns the `u`/Ctrl+x keys when that is not up,
/// and it must stay hidden while a pull it
/// already started is still running (otherwise it re-arms `u` into the "pull
/// already in progress" no-op, #2072).
fn image_banner_active(&self) -> bool {
should_show_image_banner(
self.image_update.is_some(),
self.update_status.is_some(),
self.image_pull_rx.is_some(),
)
}
/// Spawn the background sandbox-image staleness check. Mirrors
/// `spawn_update_check`: the result lands on `image_update_rx` for the
/// main loop's `poll_image_update_check` to pick up.
fn spawn_image_update_check(&mut self) {
if self.image_update_rx.is_some() {
return;
}
let image = Config::load_or_warn().sandbox.default_image.clone();
let (tx, rx) = tokio::sync::oneshot::channel();
self.image_update_rx = Some(rx);
tokio::spawn(async move {
let result = crate::containers::image_update::check_for_image_update(&image).await;
let _ = tx.send(result);
});
}
/// Poll the image-update check (non-blocking). Returns true when a fresh,
/// non-snoozed update just arrived and the banner should show.
fn poll_image_update_check(&mut self) -> bool {
let Some(mut rx) = self.image_update_rx.take() else {
return false;
};
match rx.try_recv() {
Ok(Ok(Some(update))) => {
// Honor the per-digest snooze; a newer image clears it
// automatically because its digest no longer matches.
if self.dismissed_image_digest.as_deref() == Some(update.remote_digest.as_str()) {
return false;
}
self.image_update = Some(update);
true
}
Ok(Ok(None)) => false,
Ok(Err(e)) => {
tracing::debug!(target: "containers.image_update", error = %e, "image update check failed");
false
}
Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
self.image_update_rx = Some(rx);
false
}
Err(tokio::sync::oneshot::error::TryRecvError::Closed) => false,
}
}
/// Kick off a `docker pull` of the sandbox image after the user accepts the
/// banner's confirm dialog. The blocking pull runs on a std thread (the
/// runtime call shells out); the result promotes into a transient toast.
fn spawn_image_pull(&mut self, image: String) {
if self.image_pull_rx.is_some() {
return;
}
// Persistent, not transient: a `docker pull` routinely runs longer than
// the 10s transient window, and if the toast expired mid-pull the status
// line went blank and the (still-`Some`) image banner re-rendered under
// it, clobbering itself; pressing `u` again then hit the "pull already in
// progress" guard (#2072). `poll_image_pull_status` replaces this with a
// transient success/failure toast once the pull resolves. Mirrors the
// app-update flow, which is also persistent while the install runs.
self.update_status = Some(UpdateStatus::persistent(format!("pulling {image}")));
let (tx, rx) = tokio::sync::oneshot::channel();
self.image_pull_rx = Some(rx);
std::thread::spawn(move || {
let result = crate::containers::get_container_runtime()
.pull_image(&image)
.map_err(anyhow::Error::from);
let _ = tx.send(result);
});
}
/// Poll the in-progress image pull. On success the banner clears (the local
/// copy now matches the registry) and a confirmation toast shows. Returns
/// true when the status line changed.
fn poll_image_pull_status(&mut self) -> bool {
let Some(mut rx) = self.image_pull_rx.take() else {
return false;
};
match rx.try_recv() {
Ok(Ok(())) => {
self.image_update = None;
self.update_status = Some(UpdateStatus::transient(
"sandbox image updated. New sessions will use it.".into(),
));
true
}
Ok(Err(e)) => {
self.update_status =
Some(UpdateStatus::transient(format!("image pull failed: {e}")));
true
}
Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
self.image_pull_rx = Some(rx);
false
}
Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
self.update_status = Some(UpdateStatus::transient(
"image pull ended unexpectedly".into(),
));
true
}
}
}
}
/// Persist `app_state.dismissed_image_digest` so dismissing the sandbox-image
/// banner (Ctrl+x) survives restarts. Like the update snooze, failures are
/// logged but never surfaced.
fn persist_dismissed_image_digest(digest: Option<String>) {
let result = update_app_state(|state| {
state.dismissed_image_digest = digest;
});
if let Err(e) = result {
tracing::warn!(
target: "containers.image_update",
error = %e,
"failed to persist dismissed_image_digest"
);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -2107,21 +1911,6 @@ fn decide_disk_refresh(live_idle: bool, heartbeat_due: bool, dirty: bool) -> Dis
}
}
/// Whether the sandbox-image banner should own the bottom row right now. It is
/// the lowest-priority banner, so it yields to the app-update banner and any
/// transient toast, and it stays hidden while a pull it kicked off is still
/// running. That last clause is the #2072 fix: without it the banner reappeared
/// the moment the "pulling…" toast cleared, redrawing itself on top of an
/// in-flight pull and re-arming `u` into the "pull already in progress" no-op.
/// Factored out of `App::image_banner_active` so the policy is unit-testable.
fn should_show_image_banner(
has_image_update: bool,
has_status: bool,
pull_in_flight: bool,
) -> bool {
has_image_update && !has_status && !pull_in_flight
}
/// Catches reload errors so the tick loop never propagates them. A
/// malformed `sessions.json` or `groups.json` written by a peer process
/// is logged, recorded in `ReloadFailureState` for one-shot dialog
@@ -2283,44 +2072,20 @@ impl App {
}
return Ok(());
}
// Ctrl+x dismisses the status toast / sandbox-image banner.
// Gated on something being visible AND no dialog open so it
// doesn't fire during dialog input.
// Ctrl+x dismisses the status toast. Gated on something being
// visible AND no dialog open so it doesn't fire during dialog
// input.
//
// No `needs_redraw = true` here: that forces a `clear_terminal`
// before the next event arrives, so the whole screen blanks for
// a beat (visible flash). Ratatui's diff renderer handles the
// 1-row layout shrink on the next normal draw.
(KeyCode::Char('x'), KeyModifiers::CONTROL)
if (self.update_status.is_some() || self.image_update.is_some())
&& !self.home.has_dialog() =>
if self.update_status.is_some() && !self.home.has_dialog() =>
{
// The image banner is lowest priority, so Ctrl+x only dismisses
// it when it's the one actually showing. Otherwise it targets
// the toast, leaving any pending image update to surface once
// that clears.
if self.image_banner_active() {
if let Some(update) = self.image_update.as_ref() {
let digest = update.remote_digest.clone();
self.dismissed_image_digest = Some(digest.clone());
persist_dismissed_image_digest(Some(digest));
}
self.image_update = None;
return Ok(());
}
self.update_status = None;
return Ok(());
}
// `u` on the sandbox-image banner opens the pull confirm.
(KeyCode::Char('u'), KeyModifiers::NONE)
if self.image_banner_active() && !self.home.has_dialog() =>
{
if let Some(update) = self.image_update.as_ref() {
let image = update.image.clone();
self.home.prompt_pull_sandbox_image(image);
}
return Ok(());
}
_ => {}
}
if let Some(action) = self.home.handle_key(key) {
@@ -2780,8 +2545,8 @@ impl App {
Action::AttachAfterCreate(id) => {
self.dispatch_new_session_attach(&id, terminal)?;
}
Action::AttachTerminal(id, mode) => {
self.attach_terminal(&id, mode, terminal)?;
Action::AttachTerminal(id) => {
self.attach_terminal(&id, terminal)?;
}
Action::StopSession(id) => {
if let Some(inst) = self.home.get_instance(&id) {
@@ -2808,15 +2573,6 @@ impl App {
Action::SetTransientStatus(text) => {
self.update_status = Some(UpdateStatus::transient(text));
}
Action::SpawnImagePull(image) => {
if self.image_pull_rx.is_some() {
self.update_status = Some(UpdateStatus::transient(
"image pull already in progress".into(),
));
return Ok(());
}
self.spawn_image_pull(image);
}
Action::SendMessage(id, message) => {
// Flip the row to Starting and show a toast so the user has
// visible feedback during ensure_pane_ready, which can take
@@ -2886,12 +2642,6 @@ impl App {
self.home.finalize_live_send_resize();
}
}
Action::AttachToolSession(id, tool_name) => {
self.attach_tool_session(&id, &tool_name, terminal)?;
}
Action::RunBackgroundToolSession(id, tool_name) => {
self.run_background_tool_session(&id, &tool_name);
}
#[cfg(feature = "serve")]
Action::OpenStructuredView(id) => {
// Stash for the async main loop. The acp view needs
@@ -3020,49 +2770,6 @@ impl App {
"attach_session: restart decision"
);
if needs_restart {
// Show warning (once) if custom instruction is configured for an unsupported agent
if instance.is_sandboxed() {
let has_instruction = instance
.sandbox_info
.as_ref()
.and_then(|s| s.custom_instruction.as_ref())
.is_some_and(|i| !i.is_empty());
if has_instruction
&& crate::agents::get_agent(&instance.tool)
.is_none_or(|a| a.instruction_flag.is_none())
{
let config = Config::load_or_warn();
if !config.app_state.has_seen_custom_instruction_warning {
self.home.info_dialog = Some(
crate::tui::dialogs::InfoDialog::new(
"Custom Instruction Not Supported",
&format!(
"'{}' does not support custom instruction injection. The session will launch without the custom instruction.",
instance.tool
),
),
);
self.home.pending_attach_after_warning = Some(session_id.to_string());
// Persist the "seen" flag so it only shows once. A
// failed write should not kill the interactive
// session; the dialog still shows this run either way.
if let Err(e) = update_app_state(|state| {
state.has_seen_custom_instruction_warning = true;
}) {
tracing::warn!(
target: "tui.input",
error = %e,
"failed to persist has_seen_custom_instruction_warning"
);
}
return Ok(());
}
}
}
// Get terminal size to pass to tmux session creation
// This ensures the session starts at the correct size instead of 80x24 default
let size = crate::terminal::get_size();
@@ -3160,7 +2867,6 @@ impl App {
fn attach_terminal(
&mut self,
session_id: &str,
mode: TerminalMode,
terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
) -> Result<()> {
let instance = match self.home.get_instance(session_id) {
@@ -3172,25 +2878,8 @@ impl App {
let size = crate::terminal::get_size();
// Prepare the tmux session before leaving TUI mode
let attach_fn: Box<dyn FnOnce() -> Result<()>> = match mode {
TerminalMode::Container if instance.is_sandboxed() => {
let container_session = instance.container_terminal_tmux_session()?;
if !container_session.exists() || container_session.is_pane_dead() {
if container_session.exists() {
let _ = container_session.kill();
}
if let Err(e) = self
.home
.start_container_terminal_for_instance_with_size(session_id, size)
{
self.home
.set_instance_error(session_id, Some(e.to_string()));
return Ok(());
}
}
Box::new(move || container_session.attach())
}
_ => {
let attach_fn: Box<dyn FnOnce() -> Result<()>> = {
{
let terminal_session = instance.terminal_tmux_session()?;
if !terminal_session.exists() || terminal_session.is_pane_dead() {
if terminal_session.exists() {
@@ -3229,232 +2918,16 @@ impl App {
Ok(())
}
fn attach_tool_session(
&mut self,
session_id: &str,
tool_name: &str,
terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
) -> Result<()> {
let instance = match self.home.get_instance(session_id) {
Some(inst) => inst.clone(),
None => return Ok(()),
};
let tool_config = match self.home.tool_configs.get(tool_name) {
Some(tc) => tc.clone(),
None => return Ok(()),
};
if tool_config.command.is_empty() {
self.home.set_instance_error(
session_id,
Some(format!("Tool '{}' has no command configured", tool_name)),
);
return Ok(());
}
let size = crate::terminal::get_size();
let tool_session = crate::tmux::ToolSession::new(&instance.id, &instance.title, tool_name);
if !tool_session.exists() || tool_session.is_pane_dead() {
if tool_session.exists() {
let _ = tool_session.kill();
}
if let Err(e) = tool_session.create_with_size(
&instance.project_path,
&tool_config.command,
size,
&instance.effective_profile(),
) {
self.home
.set_instance_error(session_id, Some(e.to_string()));
return Ok(());
}
// A misconfigured tool command (bad flag, missing binary) can
// exit near-instantly, leaving a `remain-on-exit`-held dead
// pane. Catch that here instead of handing a dead pane to
// `attach()`: without the SIGINT guard around the attach, the
// user's only way out (Ctrl+C) would kill aoe itself.
if let Err(e) = tool_session.wait_until_ready() {
self.home
.set_instance_error(session_id, Some(e.to_string()));
return Ok(());
}
}
let branch = instance
.worktree_info
.as_ref()
.map(|w| w.branch.as_str())
.or_else(|| instance.workspace_info.as_ref().map(|w| w.branch.as_str()));
crate::tmux::status_bar::apply_all_tmux_options(
tool_session.session_name(),
&format!("{} ({})", instance.title, tool_name),
branch,
None,
&instance.effective_profile(),
);
let attach_fn: Box<dyn FnOnce() -> Result<()>> = Box::new(move || tool_session.attach());
let (attach_result, attached_status_updates) =
self.with_attached_status_hooks(terminal, attach_fn)?;
self.needs_redraw = true;
crate::tmux::refresh_session_cache();
self.home.reload()?;
self.home
.apply_status_updates_without_hooks(attached_status_updates);
self.home.select_session_by_id(session_id);
if let Err(e) = attach_result {
tracing::warn!(
"tmux tool session '{}' attach returned error: {}",
tool_name,
e
);
}
Ok(())
}
fn run_background_tool_session(&mut self, session_id: &str, tool_name: &str) {
let instance = match self.home.get_instance(session_id) {
Some(inst) => inst.clone(),
None => {
self.update_status = Some(UpdateStatus::transient(format!(
"Tool '{}' failed: session not found",
tool_name
)));
return;
}
};
let tool_config = match self.home.tool_configs.get(tool_name) {
Some(tc) => tc.clone(),
None => {
self.update_status = Some(UpdateStatus::transient(format!(
"Tool '{}' is not configured",
tool_name
)));
return;
}
};
match spawn_background_tool(
session_id,
tool_name,
&instance.project_path,
&tool_config.command,
) {
Ok(()) => {
self.update_status = Some(UpdateStatus::transient(format!(
"Started background tool: {}",
tool_name
)));
}
Err(e) => {
self.update_status = Some(UpdateStatus::transient(format!(
"Failed to start background tool '{}': {}",
tool_name, e
)));
}
}
}
}
fn spawn_background_tool(
session_id: &str,
tool_name: &str,
working_dir: &str,
command: &str,
) -> Result<()> {
if command.trim().is_empty() {
anyhow::bail!("Tool '{}' has no command configured", tool_name);
}
let shell = crate::session::environment::user_shell();
let mut child_command = std::process::Command::new(&shell);
child_command
.arg("-c")
.arg(command)
.current_dir(working_dir)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
child_command.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x0000_0008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
child_command.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
}
let child = child_command.spawn().with_context(|| {
format!(
"spawn background tool '{}' with shell '{}'",
tool_name, shell
)
})?;
wait_for_background_tool(session_id, tool_name, child);
Ok(())
}
fn wait_for_background_tool(session_id: &str, tool_name: &str, mut child: std::process::Child) {
let session_id = session_id.to_string();
let tool_name = tool_name.to_string();
std::thread::spawn(move || match child.wait() {
Ok(status) if status.success() => {
tracing::debug!(
target: "tui.tools",
session_id = %session_id,
tool = %tool_name,
status = %status,
"background tool exited"
);
}
Ok(status) => {
tracing::warn!(
target: "tui.tools",
session_id = %session_id,
tool = %tool_name,
status = %status,
"background tool exited unsuccessfully"
);
}
Err(e) => {
tracing::warn!(
target: "tui.tools",
session_id = %session_id,
tool = %tool_name,
error = %e,
"failed waiting for background tool"
);
}
});
}
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
Quit,
AttachSession(String),
AttachTerminal(String, TerminalMode),
AttachTerminal(String),
StopSession(String),
SetTheme(String),
SetTransientStatus(String),
/// Pull the sandbox image after the user accepts the "image update
/// available" banner's confirm. Deferred to `execute_action` so the loop
/// can show a "pulling…" status before the blocking pull starts.
SpawnImagePull(String),
/// Send a message to a session. Deferred to `execute_action` (rather
/// than handled inline in the dialog Submit branch) so the app loop
/// can render a "Reviving..." status before the potentially-slow
@@ -3473,12 +2946,6 @@ pub enum Action {
/// bypasses the setting because pressing Enter on a session row is
/// the user's explicit ask for a tmux attach.
AttachAfterCreate(String),
/// Attach to a tool session (lazygit, yazi, etc.) for the given agent
/// session. The tool_name indexes into Config.tools.
AttachToolSession(String, String),
/// Run a configured tool command without creating or attaching a tmux tool
/// session. The command runs in the selected agent session's workdir.
RunBackgroundToolSession(String, String),
/// Open the native acp view for `session_id`. The action handler
/// stashes the id in `pending_structured_view_open`; the main loop drains it
/// after `execute_action` returns and runs the async acp loop
@@ -3615,27 +3082,6 @@ mod tests {
);
}
#[test]
fn image_banner_shows_only_when_it_owns_the_row() {
// The plain case: an image update is pending and nothing outranks it.
assert!(should_show_image_banner(true, false, false));
// No pending update: nothing to show.
assert!(!should_show_image_banner(false, false, false));
// A transient toast outranks it.
assert!(!should_show_image_banner(true, true, false));
}
#[test]
fn image_banner_stays_hidden_while_its_own_pull_runs() {
// #2072: once the user accepts the pull, the banner must stay down for
// the whole `docker pull`. Even after the "pulling…" toast clears
// (has_status = false) the in-flight pull keeps the banner hidden, so it
// can't redraw under the pull and re-arm `u` into "pull already in
// progress".
assert!(!should_show_image_banner(true, false, true));
assert!(!should_show_image_banner(true, true, true));
}
#[test]
fn ctrl_q_never_quits() {
// The whole point of #1569: Ctrl+Q is a live-mode-exit habit and
@@ -3772,32 +3218,6 @@ mod tests {
assert!(!disk_dirty.load(std::sync::atomic::Ordering::Acquire));
}
#[test]
fn test_action_enum() {
let quit = Action::Quit;
let attach = Action::AttachSession("test-id".to_string());
let attach_terminal =
Action::AttachTerminal("test-id".to_string(), TerminalMode::Container);
assert_eq!(quit, Action::Quit);
assert_eq!(attach, Action::AttachSession("test-id".to_string()));
assert_eq!(
attach_terminal,
Action::AttachTerminal("test-id".to_string(), TerminalMode::Container)
);
}
#[test]
fn test_action_clone() {
let original = Action::AttachSession("session-123".to_string());
let cloned = original.clone();
assert_eq!(original, cloned);
let terminal_action = Action::AttachTerminal("session-123".to_string(), TerminalMode::Host);
let terminal_cloned = terminal_action.clone();
assert_eq!(terminal_action, terminal_cloned);
}
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, mods)
}
+5 -87
View File
@@ -53,33 +53,13 @@ fn agent_name_width(table_width: u16) -> usize {
.max(8)
}
/// Marker on a sandboxed row, matching the session list's `[container]` badge.
const CONTAINER_BADGE: &str = " [container]";
/// The name cell: the agent title, plus the container marker when the row's
/// figures come from a sandbox rather than a host process tree (its memory
/// figure is the container's usage, not an RSS sum). The spans always total
/// `width`, so the metric columns stay under their headers.
/// The name cell: the agent title padded to `width`, so the metric columns
/// stay under their headers.
fn agent_name_spans<'a>(agent: &AgentMetric, width: usize, theme: &Theme) -> Vec<Span<'a>> {
// Below this the badge would crowd out the name; the row keeps its
// numbers and just loses the marker.
let show_badge = agent.sandboxed && width >= CONTAINER_BADGE.width() + 8;
let title_width = if show_badge {
width - CONTAINER_BADGE.width()
} else {
width
};
let mut spans = vec![Span::styled(
format_agent_title(&agent.title, title_width),
vec![Span::styled(
format_agent_title(&agent.title, width),
Style::default().fg(theme.text),
)];
if show_badge {
spans.push(Span::styled(
CONTAINER_BADGE,
Style::default().fg(theme.sandbox),
));
}
spans
)]
}
/// The fixed-width CPU / Mem / Procs block of an agent row. A figure the
@@ -508,51 +488,6 @@ mod tests {
}
}
#[test]
fn agent_table_header_and_rows_share_column_offsets() {
// The metric block is fixed-width on both lines, so the name cell must
// be the same width on both or the CPU/RSS/Procs columns drift apart.
assert_eq!(
format!("{:>7} {:>9} {:>6}", "CPU", "RSS", "Procs").width(),
AGENT_METRICS_WIDTH
);
assert_eq!(
agent_metrics_cell(&AgentMetric {
cpu_fraction: Some(0.999),
rss_bytes: Some(22 * (1 << 30)),
procs: Some(12),
..AgentMetric::default()
})
.width(),
AGENT_METRICS_WIDTH
);
// A sandbox with no runtime sample yet still fills its columns.
assert_eq!(
agent_metrics_cell(&AgentMetric {
sandboxed: true,
..AgentMetric::default()
}),
format!("{:>7} {:>9} {:>6}", "?", "?", "?")
);
let theme = Theme::default();
for table_width in [20u16, 32, 48, 60, 120] {
let name_width = agent_name_width(table_width);
let header = format!("{:<name_width$}", "Agent").width();
for sandboxed in [false, true] {
let agent = AgentMetric {
title: "some agent title".into(),
sandboxed,
..AgentMetric::default()
};
let cell: usize = agent_name_spans(&agent, name_width, &theme)
.iter()
.map(|s| s.width())
.sum();
assert_eq!(header, cell, "table width {table_width}, {sandboxed}");
}
}
}
#[test]
fn health_padding_never_starves_the_agent_table() {
// The gutter must not cost so much width that the metrics block and a
@@ -571,23 +506,6 @@ mod tests {
}
}
#[test]
fn container_badge_is_dropped_when_the_name_cell_is_narrow() {
let theme = Theme::default();
let agent = AgentMetric {
title: "sandboxed agent".into(),
sandboxed: true,
..AgentMetric::default()
};
let badged = |width| {
agent_name_spans(&agent, width, &theme)
.iter()
.any(|s| s.content.contains("[container]"))
};
assert!(!badged(CONTAINER_BADGE.width() + 7));
assert!(badged(CONTAINER_BADGE.width() + 8));
}
#[test]
fn format_bytes_table() {
let gib = 1u64 << 30;
+8 -100
View File
@@ -46,32 +46,24 @@ impl<'a> CachedPreview<'a> {
/// clipped on every frame and the user sees content shifted up.
pub fn agent_info_height(instance: &Instance) -> u16 {
let base: u16 = 3; // profile+tool / path / status
let sandbox_lines: u16 = if instance.is_sandboxed() { 1 } else { 0 };
if let Some(wt) = instance.worktree_info.as_ref() {
// blank + header + branch + main (+ optional base)
let base_branch_line: u16 = if wt.base_branch.is_some() { 1 } else { 0 };
base + sandbox_lines + 4 + base_branch_line
base + 4 + base_branch_line
} else {
base + sandbox_lines
base
}
}
/// Row count of the Terminal-view (and Tool-view) info header
/// (title / path / status, plus one optional sandbox row) for
/// Row count of the Terminal-view info header (title / path / status) for
/// `instance`.
///
/// Symmetric with [`agent_info_height`]: the live-send sync resize
/// against a terminal target needs the OUTPUT portion of the preview
/// pane, which is `inner.height - terminal_info_height(inst) - 1`
/// (info header + one row for the inner ` Terminal Output ` banner).
pub fn terminal_info_height(instance: &Instance) -> u16 {
let base: u16 = 3; // title / path / status
let sandbox_lines: u16 = if instance.sandbox_info.as_ref().is_some_and(|s| s.enabled) {
1
} else {
0
};
base + sandbox_lines
pub fn terminal_info_height(_instance: &Instance) -> u16 {
3 // title / path / status
}
/// The geometry of the preview body, computed once so every consumer agrees on
@@ -170,7 +162,7 @@ impl Preview {
if let Some(info_area) = layout.info {
// Minimal info for terminal view.
let mut info_lines = vec![
let info_lines = vec![
Line::from(vec![
Span::styled("Title: ", Style::default().fg(theme.dimmed)),
Span::styled(&instance.title, Style::default().fg(theme.text).bold()),
@@ -198,14 +190,6 @@ impl Preview {
),
]),
];
if let Some(sandbox) = &instance.sandbox_info {
if sandbox.enabled {
info_lines.push(Line::from(vec![
Span::styled("Sandbox: ", Style::default().fg(theme.dimmed)),
Span::styled(&sandbox.container_name, Style::default().fg(theme.sandbox)),
]));
}
}
frame.render_widget(Paragraph::new(info_lines), info_area);
}
@@ -364,16 +348,6 @@ impl Preview {
]),
]);
// Add sandbox information if present
if let Some(sandbox) = &instance.sandbox_info {
if sandbox.enabled {
info_lines.push(Line::from(vec![
Span::styled("Sandbox: ", Style::default().fg(theme.dimmed)),
Span::styled(&sandbox.container_name, Style::default().fg(theme.sandbox)),
]));
}
}
// Add worktree information if present
if let Some(wt_info) = &instance.worktree_info {
info_lines.push(Line::from(""));
@@ -797,7 +771,7 @@ mod tests {
// gets a dedicated case.
mod agent_info_height {
use super::super::agent_info_height;
use crate::session::{Instance, SandboxInfo, WorktreeInfo};
use crate::session::{Instance, WorktreeInfo};
use chrono::Utc;
fn worktree(base_branch: Option<&str>) -> WorktreeInfo {
@@ -810,32 +784,12 @@ mod tests {
}
}
fn enabled_sandbox() -> SandboxInfo {
SandboxInfo {
enabled: true,
container_id: None,
image: "img".into(),
container_name: "ctr".into(),
extra_env: None,
custom_instruction: None,
before_start_env: Vec::new(),
container_workdir: None,
}
}
#[test]
fn plain_session_is_three_rows() {
let inst = Instance::new("plain", "/tmp/plain");
assert_eq!(agent_info_height(&inst), 3);
}
#[test]
fn sandboxed_adds_one_row() {
let mut inst = Instance::new("sandboxed", "/tmp/sandboxed");
inst.sandbox_info = Some(enabled_sandbox());
assert_eq!(agent_info_height(&inst), 4);
}
#[test]
fn worktree_without_base_branch_adds_four_rows() {
let mut inst = Instance::new("wt", "/tmp/wt");
@@ -849,23 +803,6 @@ mod tests {
inst.worktree_info = Some(worktree(Some("main")));
assert_eq!(agent_info_height(&inst), 3 + 4 + 1);
}
#[test]
fn sandboxed_plus_worktree_with_base_branch_is_max() {
let mut inst = Instance::new("both", "/tmp/both");
inst.sandbox_info = Some(enabled_sandbox());
inst.worktree_info = Some(worktree(Some("main")));
assert_eq!(agent_info_height(&inst), 3 + 1 + 4 + 1);
}
#[test]
fn disabled_sandbox_does_not_count() {
let mut inst = Instance::new("disabled", "/tmp/disabled");
let mut sandbox = enabled_sandbox();
sandbox.enabled = false;
inst.sandbox_info = Some(sandbox);
assert_eq!(agent_info_height(&inst), 3);
}
}
// Terminal-view counterpart of `agent_info_height`. Same drift-guard
@@ -874,44 +811,15 @@ mod tests {
// formula here brings the shifted-preview bug back in Terminal view.
mod terminal_info_height {
use super::super::terminal_info_height;
use crate::session::{Instance, SandboxInfo, WorktreeInfo};
use crate::session::{Instance, WorktreeInfo};
use chrono::Utc;
fn enabled_sandbox() -> SandboxInfo {
SandboxInfo {
enabled: true,
container_id: None,
image: "img".into(),
container_name: "ctr".into(),
extra_env: None,
custom_instruction: None,
before_start_env: Vec::new(),
container_workdir: None,
}
}
#[test]
fn plain_session_is_three_rows() {
let inst = Instance::new("plain", "/tmp/plain");
assert_eq!(terminal_info_height(&inst), 3);
}
#[test]
fn sandboxed_adds_one_row() {
let mut inst = Instance::new("sandboxed", "/tmp/sandboxed");
inst.sandbox_info = Some(enabled_sandbox());
assert_eq!(terminal_info_height(&inst), 4);
}
#[test]
fn disabled_sandbox_does_not_count() {
let mut inst = Instance::new("disabled", "/tmp/disabled");
let mut sandbox = enabled_sandbox();
sandbox.enabled = false;
inst.sandbox_info = Some(sandbox);
assert_eq!(terminal_info_height(&inst), 3);
}
#[test]
fn worktree_info_does_not_count() {
// Worktree info is an Agent-view-only block; the terminal
+2 -79
View File
@@ -110,7 +110,6 @@ impl CreationPoller {
let data = request.data;
let hooks = request.hooks;
let profile = data.profile.clone();
let sandbox = data.sandbox;
let existing_titles: Vec<&str> = request
.existing_instances
@@ -153,46 +152,12 @@ impl CreationPoller {
let has_on_create = hooks.as_ref().is_some_and(|h| !h.on_create.is_empty());
let has_on_launch = hooks.as_ref().is_some_and(|h| !h.on_launch.is_empty());
let mut container_started = false;
let hook_env = repo_config::lifecycle_env_vars(&instance);
// Execute on_create hooks after worktree setup, before starting
if has_on_create {
let hooks = hooks.as_ref().unwrap();
if sandbox {
// Ensure the container is running so we can exec hooks inside it.
// Don't create the tmux session yet -- that happens at attach time
// where the terminal size is available.
if let Err(e) = instance.get_container_for_instance() {
builder::cleanup_instance(
&instance,
created_worktree.as_ref(),
&created_workspace_worktrees,
None,
);
return CreationResult::Error(format!("{:#}", e));
}
container_started = true;
if let Some(ref sandbox) = instance.sandbox_info {
let workdir = instance.container_workdir();
if let Err(e) = repo_config::execute_hooks_in_container_streamed(
&hooks.on_create,
&sandbox.container_name,
&workdir,
progress_tx,
&hook_env,
) {
tracing::warn!(target: "session.create", "on_create hook failed in container: {:#}", e);
builder::cleanup_instance(
&instance,
created_worktree.as_ref(),
&created_workspace_worktrees,
None,
);
return CreationResult::Error(format!("on_create hook failed: {:#}", e));
}
}
} else if let Err(e) = repo_config::execute_hooks_streamed(
if let Err(e) = repo_config::execute_hooks_streamed(
&hooks.on_create,
std::path::Path::new(&instance.project_path),
progress_tx,
@@ -212,31 +177,7 @@ impl CreationPoller {
// This prevents blocking the UI thread when the session is first attached.
if has_on_launch {
let hooks = hooks.as_ref().unwrap();
if sandbox {
if !container_started {
if let Err(e) = instance.get_container_for_instance() {
let msg = format!("Container startup warning: {:#}", e);
tracing::warn!(target: "session.create", "{}", msg);
let _ = progress_tx.send(HookProgress::Output(msg));
} else {
container_started = true;
}
}
if container_started {
if let Some(ref sandbox) = instance.sandbox_info {
let workdir = instance.container_workdir();
if let Err(e) = repo_config::execute_hooks_in_container_streamed(
&hooks.on_launch,
&sandbox.container_name,
&workdir,
progress_tx,
&hook_env,
) {
tracing::warn!(target: "session.create", "on_launch hook failed in container: {}", e);
}
}
}
} else if let Err(e) = repo_config::execute_hooks_streamed(
if let Err(e) = repo_config::execute_hooks_streamed(
&hooks.on_launch,
std::path::Path::new(&instance.project_path),
progress_tx,
@@ -246,21 +187,6 @@ impl CreationPoller {
}
}
if sandbox && !container_started {
// Only ensure the container is running here if hooks didn't already
// start it. Don't create the tmux session yet -- that happens at attach time
// where the terminal size is available.
if let Err(e) = instance.get_container_for_instance() {
builder::cleanup_instance(
&instance,
created_worktree.as_ref(),
&created_workspace_worktrees,
None,
);
return CreationResult::Error(format!("{:#}", e));
}
}
let created_worktree_info = created_worktree.as_ref().map(CreatedWorktreeInfo::from);
let created_workspace_worktree_info = created_workspace_worktrees
.iter()
@@ -348,10 +274,7 @@ mod tests {
create_new_branch: false,
base_branch: None,
extra_repo_paths: Vec::new(),
sandbox: false,
sandbox_image: String::new(),
yolo_mode: false,
extra_env: Vec::new(),
extra_args: String::new(),
command_override: String::new(),
scratch: false,
-1
View File
@@ -56,7 +56,6 @@ mod tests {
instance,
delete_worktree: false,
delete_branch: false,
delete_sandbox: false,
force_delete: false,
detach_hooks: true,
keep_scratch: false,
-10
View File
@@ -66,8 +66,6 @@ pub enum PaletteAction {
LiveSend,
/// Move the cursor to a position in `flat_items` (used for session/group jump items).
JumpToCursor(usize),
/// Open a tool session by name (lazygit, yazi, etc.)
ToolSession(String),
}
/// One entry in the palette. `payload` is what gets returned when the user picks it.
@@ -703,18 +701,10 @@ mod tests {
ActionId::Quit,
"intentionally excluded from the palette; q is quick-exit, doesn't need discovery",
),
(
ActionId::ToolPicker,
"Tool-view toggle; tool sessions get dynamic palette entries instead",
),
(
ActionId::SearchStart,
"search activation; modal trigger, not an action",
),
(
ActionId::ToggleContainer,
"only valid on a sandboxed session in Terminal view",
),
(
ActionId::SearchNext,
"search-cycle; only meaningful while a search is active",
-7
View File
@@ -129,13 +129,6 @@ impl ConfirmDialog {
&self.action
}
/// The `[Yes]` button hit-rect, populated on `render`. Test-only so a
/// click path can be exercised at the exact coordinates the dialog draws.
#[cfg(test)]
pub(crate) fn yes_button_area_for_test(&self) -> ratatui::layout::Rect {
self.yes_button_area
}
/// Whether `c` is the opt-in confirm key, ignoring ASCII case.
fn is_confirm_char(&self, c: char) -> bool {
self.confirm_char
+3 -110
View File
@@ -16,7 +16,6 @@ pub struct DeleteOptions {
pub delete_worktree: bool,
pub force_delete: bool,
pub delete_branch: bool,
pub delete_sandbox: bool,
/// For scratch sessions: keep the scratch directory on disk instead of
/// removing it. No effect when `DeleteDialogConfig.is_scratch` is false.
pub keep_scratch: bool,
@@ -26,7 +25,6 @@ pub struct DeleteOptions {
#[derive(Clone, Debug, Default)]
pub struct DeleteDialogConfig {
pub worktree_branch: Option<String>,
pub has_sandbox: bool,
/// Project path used to load repo-level config overrides.
pub project_path: Option<String>,
/// True iff the session being deleted is a scratch session. Surfaces a
@@ -41,7 +39,6 @@ enum FocusElement {
WorktreeCheckbox,
ForceCheckbox,
BranchCheckbox,
SandboxCheckbox,
KeepScratchCheckbox,
YesButton,
NoButton,
@@ -87,7 +84,6 @@ impl UnifiedDeleteDialog {
force_delete: false,
delete_branch: config.worktree_branch.is_some()
&& user_config.worktree.delete_branch_on_cleanup,
delete_sandbox: config.has_sandbox && user_config.sandbox.auto_cleanup,
// Scratch sessions default to remove. The user has to explicitly
// opt in to keep the directory.
keep_scratch: false,
@@ -95,8 +91,6 @@ impl UnifiedDeleteDialog {
let initial_focus = if config.worktree_branch.is_some() {
FocusElement::WorktreeCheckbox
} else if config.has_sandbox {
FocusElement::SandboxCheckbox
} else if config.is_scratch {
FocusElement::KeepScratchCheckbox
} else {
@@ -186,9 +180,6 @@ impl UnifiedDeleteDialog {
FocusElement::BranchCheckbox => {
self.options.delete_branch = !self.options.delete_branch;
}
FocusElement::SandboxCheckbox => {
self.options.delete_sandbox = !self.options.delete_sandbox;
}
FocusElement::KeepScratchCheckbox => {
self.options.keep_scratch = !self.options.keep_scratch;
}
@@ -208,9 +199,6 @@ impl UnifiedDeleteDialog {
}
elements.push(FocusElement::BranchCheckbox);
}
if config.has_sandbox {
elements.push(FocusElement::SandboxCheckbox);
}
if config.is_scratch {
elements.push(FocusElement::KeepScratchCheckbox);
}
@@ -317,15 +305,12 @@ impl UnifiedDeleteDialog {
// stale hit rects pointing at the wrong cells.
self.focusable_rects.clear();
let has_worktree = self.config.worktree_branch.is_some();
let has_sandbox = self.config.has_sandbox;
let is_scratch = self.config.is_scratch;
let show_force = has_worktree && self.options.delete_worktree;
// Count checkbox rows: worktree + force (if worktree checked) +
// branch (if worktree exists) + sandbox + keep-scratch (if scratch).
let checkbox_count = if has_worktree { 2 } else { 0 }
+ (show_force as u16)
+ (has_sandbox as u16)
+ (is_scratch as u16);
// branch (if worktree exists) + keep-scratch (if scratch).
let checkbox_count =
if has_worktree { 2 } else { 0 } + (show_force as u16) + (is_scratch as u16);
let dialog_width = 55;
let dialog_height = if checkbox_count > 0 {
@@ -430,23 +415,6 @@ impl UnifiedDeleteDialog {
chunk_idx += 1;
}
if has_sandbox {
let area = chunks[chunk_idx];
let focused = self.focus == FocusElement::SandboxCheckbox;
self.render_checkbox(
frame,
area,
theme,
"Delete container",
None,
self.options.delete_sandbox,
focused,
);
self.focusable_rects
.push((FocusElement::SandboxCheckbox, area));
chunk_idx += 1;
}
if is_scratch {
let area = chunks[chunk_idx];
let focused = self.focus == FocusElement::KeepScratchCheckbox;
@@ -583,7 +551,6 @@ mod tests {
"Test Session".to_string(),
DeleteDialogConfig {
worktree_branch: Some("feature-branch".to_string()),
has_sandbox: true,
project_path: None,
is_scratch: false,
},
@@ -596,7 +563,6 @@ mod tests {
"Scratch Session".to_string(),
DeleteDialogConfig {
worktree_branch: None,
has_sandbox: false,
project_path: None,
is_scratch: true,
},
@@ -604,15 +570,6 @@ mod tests {
)
}
#[test]
fn test_default_options() {
let options = DeleteOptions::default();
assert!(!options.delete_worktree);
assert!(!options.force_delete);
assert!(!options.delete_branch);
assert!(!options.delete_sandbox);
}
#[test]
fn test_simple_dialog_focuses_no_button() {
let dialog = simple_dialog();
@@ -625,47 +582,6 @@ mod tests {
assert_eq!(dialog.focus, FocusElement::WorktreeCheckbox);
}
#[test]
fn test_full_dialog_respects_config_defaults() {
let dialog = full_dialog();
assert!(
dialog.options.delete_worktree,
"With default config (auto_cleanup: true), delete_worktree should be true"
);
assert!(
!dialog.options.delete_branch,
"With default config (delete_branch_on_cleanup: false), delete_branch should be false"
);
assert!(
dialog.options.delete_sandbox,
"With default config (auto_cleanup: true), delete_sandbox should be true"
);
}
#[test]
fn test_tab_cycles_through_elements() {
let mut dialog = full_dialog();
assert_eq!(dialog.focus, FocusElement::WorktreeCheckbox);
dialog.handle_key(key(KeyCode::Tab));
assert_eq!(dialog.focus, FocusElement::ForceCheckbox);
dialog.handle_key(key(KeyCode::Tab));
assert_eq!(dialog.focus, FocusElement::BranchCheckbox);
dialog.handle_key(key(KeyCode::Tab));
assert_eq!(dialog.focus, FocusElement::SandboxCheckbox);
dialog.handle_key(key(KeyCode::Tab));
assert_eq!(dialog.focus, FocusElement::YesButton);
dialog.handle_key(key(KeyCode::Tab));
assert_eq!(dialog.focus, FocusElement::NoButton);
dialog.handle_key(key(KeyCode::Tab));
assert_eq!(dialog.focus, FocusElement::WorktreeCheckbox);
}
#[test]
fn test_branch_checkbox_toggle() {
let mut dialog = full_dialog();
@@ -798,26 +714,6 @@ mod tests {
assert!(!dialog.options.keep_scratch);
}
#[test]
fn test_submit_returns_options() {
let mut dialog = full_dialog();
dialog.options.delete_worktree = true;
dialog.options.force_delete = true;
dialog.options.delete_branch = true;
dialog.options.delete_sandbox = true;
let result = dialog.handle_key(key(KeyCode::Char('y')));
match result {
DialogResult::Submit(opts) => {
assert!(opts.delete_worktree);
assert!(opts.force_delete);
assert!(opts.delete_branch);
assert!(opts.delete_sandbox);
}
_ => panic!("Expected Submit"),
}
}
/// Stage button + checkbox rects manually (the real ones come from
/// `render`, which is impractical to invoke in a unit test).
fn stage_rects_for_simple(dialog: &mut UnifiedDeleteDialog) {
@@ -840,9 +736,6 @@ mod tests {
dialog
.focusable_rects
.push((FocusElement::BranchCheckbox, Rect::new(5, 5, 30, 1)));
dialog
.focusable_rects
.push((FocusElement::SandboxCheckbox, Rect::new(5, 6, 30, 1)));
dialog.yes_button_area = Rect::new(10, 10, 5, 1);
dialog.no_button_area = Rect::new(19, 10, 4, 1);
}
+5 -162
View File
@@ -15,14 +15,12 @@ pub struct GroupDeleteOptions {
pub delete_worktrees: bool,
pub force_delete_worktrees: bool,
pub delete_branches: bool,
pub delete_containers: bool,
}
pub struct GroupDeleteOptionsDialog {
group_path: String,
session_count: usize,
has_managed_worktrees: bool,
has_containers: bool,
options: GroupDeleteOptions,
focused_field: usize,
/// Captured rect per focusable field, populated by `render`.
@@ -34,17 +32,11 @@ pub struct GroupDeleteOptionsDialog {
}
impl GroupDeleteOptionsDialog {
pub fn new(
group_path: String,
session_count: usize,
has_managed_worktrees: bool,
has_containers: bool,
) -> Self {
pub fn new(group_path: String, session_count: usize, has_managed_worktrees: bool) -> Self {
Self {
group_path,
session_count,
has_managed_worktrees,
has_containers,
options: GroupDeleteOptions::default(),
focused_field: 0,
focusable_rects: Vec::new(),
@@ -82,7 +74,6 @@ impl GroupDeleteOptionsDialog {
self.options.delete_worktrees = false;
self.options.force_delete_worktrees = false;
self.options.delete_branches = false;
self.options.delete_containers = false;
}
1 => {
self.options.delete_sessions = true;
@@ -99,9 +90,6 @@ impl GroupDeleteOptionsDialog {
f if Some(f) == self.branch_field_index() => {
self.options.delete_branches = !self.options.delete_branches;
}
f if Some(f) == self.container_field_index() => {
self.options.delete_containers = !self.options.delete_containers;
}
_ => {}
}
}
@@ -117,9 +105,6 @@ impl GroupDeleteOptionsDialog {
count += 1; // force checkbox
}
}
if self.has_containers {
count += 1; // container checkbox
}
count
}
@@ -154,22 +139,6 @@ impl GroupDeleteOptionsDialog {
}
}
fn container_field_index(&self) -> Option<usize> {
if self.options.delete_sessions && self.has_containers {
let base = 2;
let mut offset = 0;
if self.has_managed_worktrees {
offset += 2; // worktree + branch
if self.options.delete_worktrees {
offset += 1; // force
}
}
Some(base + offset)
} else {
None
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> DialogResult<GroupDeleteOptions> {
match key.code {
KeyCode::Esc => DialogResult::Cancel,
@@ -212,7 +181,6 @@ impl GroupDeleteOptionsDialog {
self.focusable_rects.clear();
let show_worktree_option = self.options.delete_sessions && self.has_managed_worktrees;
let show_force_option = show_worktree_option && self.options.delete_worktrees;
let show_container_option = self.options.delete_sessions && self.has_containers;
let dialog_width = 50;
let mut dialog_height = 11; // Base height
if show_worktree_option {
@@ -221,9 +189,6 @@ impl GroupDeleteOptionsDialog {
dialog_height += 1; // force checkbox
}
}
if show_container_option {
dialog_height += 1;
}
let dialog_area = super::centered_rect(area, dialog_width, dialog_height);
@@ -252,9 +217,6 @@ impl GroupDeleteOptionsDialog {
}
constraints.push(Constraint::Length(1)); // Branch checkbox
}
if show_container_option {
constraints.push(Constraint::Length(1)); // Container checkbox
}
constraints.push(Constraint::Min(1)); // Hints
let chunks = Layout::default()
@@ -377,25 +339,6 @@ impl GroupDeleteOptionsDialog {
next_chunk += 1;
}
// Container checkbox (only shown when delete is selected and has containers)
if show_container_option {
let ct_focused = Some(self.focused_field) == self.container_field_index();
let ct_line = checkbox_line(
theme,
"Also delete containers",
None,
4,
self.options.delete_containers,
ct_focused,
style,
);
frame.render_widget(Paragraph::new(ct_line), chunks[next_chunk]);
if let Some(idx) = self.container_field_index() {
self.focusable_rects.push((idx, chunks[next_chunk]));
}
next_chunk += 1;
}
// Hints
let hints = Line::from(vec![
Span::styled("Tab", Style::default().fg(theme.hint)),
@@ -433,29 +376,19 @@ mod tests {
}
fn dialog() -> GroupDeleteOptionsDialog {
GroupDeleteOptionsDialog::new("work".to_string(), 3, false, false)
GroupDeleteOptionsDialog::new("work".to_string(), 3, false)
}
fn dialog_with_worktrees() -> GroupDeleteOptionsDialog {
GroupDeleteOptionsDialog::new("work".to_string(), 3, true, false)
GroupDeleteOptionsDialog::new("work".to_string(), 3, true)
}
fn dialog_with_containers() -> GroupDeleteOptionsDialog {
GroupDeleteOptionsDialog::new("work".to_string(), 3, false, true)
GroupDeleteOptionsDialog::new("work".to_string(), 3, false)
}
fn dialog_with_both() -> GroupDeleteOptionsDialog {
GroupDeleteOptionsDialog::new("work".to_string(), 3, true, true)
}
#[test]
fn test_default_options() {
let options = GroupDeleteOptions::default();
assert!(!options.delete_sessions);
assert!(!options.delete_worktrees);
assert!(!options.force_delete_worktrees);
assert!(!options.delete_branches);
assert!(!options.delete_containers);
GroupDeleteOptionsDialog::new("work".to_string(), 3, true)
}
#[test]
@@ -623,18 +556,6 @@ mod tests {
assert!(!dialog.options.delete_branches);
}
#[test]
fn test_selecting_move_clears_delete_containers() {
let mut dialog = dialog_with_containers();
dialog.options.delete_sessions = true;
dialog.options.delete_containers = true;
dialog.focused_field = 0;
dialog.handle_key(key(KeyCode::Char(' ')));
assert!(!dialog.options.delete_sessions);
assert!(!dialog.options.delete_containers);
}
#[test]
fn test_container_checkbox_appears_when_delete_selected() {
let mut dialog = dialog_with_containers();
@@ -644,20 +565,6 @@ mod tests {
assert_eq!(dialog.max_field(), 3); // Now container option is available
}
#[test]
fn test_container_checkbox_toggle() {
let mut dialog = dialog_with_containers();
dialog.options.delete_sessions = true;
dialog.focused_field = 2; // Container is at index 2 when no worktrees
assert!(!dialog.options.delete_containers);
dialog.handle_key(key(KeyCode::Char(' ')));
assert!(dialog.options.delete_containers);
dialog.handle_key(key(KeyCode::Char(' ')));
assert!(!dialog.options.delete_containers);
}
#[test]
fn test_tab_includes_container_when_delete_selected() {
let mut dialog = dialog_with_containers();
@@ -705,70 +612,6 @@ mod tests {
assert_eq!(dialog.focused_field, 0); // Wrap around
}
#[test]
fn test_container_checkbox_at_correct_index_with_worktrees() {
let mut dialog = dialog_with_both();
dialog.options.delete_sessions = true;
// Worktree is at index 2
assert_eq!(dialog.worktree_field_index(), Some(2));
// Branch is at index 3 (after worktree)
assert_eq!(dialog.branch_field_index(), Some(3));
// Container is at index 4 (after branch)
assert_eq!(dialog.container_field_index(), Some(4));
}
#[test]
fn test_container_checkbox_at_correct_index_without_worktrees() {
let mut dialog = dialog_with_containers();
dialog.options.delete_sessions = true;
// No worktree
assert_eq!(dialog.worktree_field_index(), None);
// Container is at index 2
assert_eq!(dialog.container_field_index(), Some(2));
}
#[test]
fn test_submit_with_all_options() {
let mut dialog = dialog_with_both();
dialog.options.delete_sessions = true;
dialog.options.delete_worktrees = true;
dialog.options.force_delete_worktrees = true;
dialog.options.delete_branches = true;
dialog.options.delete_containers = true;
let result = dialog.handle_key(key(KeyCode::Enter));
match result {
DialogResult::Submit(opts) => {
assert!(opts.delete_sessions);
assert!(opts.delete_worktrees);
assert!(opts.force_delete_worktrees);
assert!(opts.delete_branches);
assert!(opts.delete_containers);
}
_ => panic!("Expected Submit"),
}
}
#[test]
fn test_selecting_move_clears_all_options() {
let mut dialog = dialog_with_both();
dialog.options.delete_sessions = true;
dialog.options.delete_worktrees = true;
dialog.options.force_delete_worktrees = true;
dialog.options.delete_branches = true;
dialog.options.delete_containers = true;
dialog.focused_field = 0;
dialog.handle_key(key(KeyCode::Char(' ')));
assert!(!dialog.options.delete_sessions);
assert!(!dialog.options.delete_worktrees);
assert!(!dialog.options.force_delete_worktrees);
assert!(!dialog.options.delete_branches);
assert!(!dialog.options.delete_containers);
}
#[test]
fn hover_highlights_row_without_moving_focus() {
// Stage focusable rects manually; the real ones come from render().
-2
View File
@@ -22,7 +22,6 @@ mod send_message;
#[cfg(feature = "serve")]
mod serve;
pub mod sort_picker;
mod tool_picker;
mod worktree_name;
pub use command_palette::{
@@ -52,7 +51,6 @@ pub(crate) use serve::start_local_daemon_and_wait;
#[cfg(feature = "serve")]
pub use serve::{ServeAction, ServeView};
pub use sort_picker::SortPickerDialog;
pub use tool_picker::ToolPickerDialog;
pub use worktree_name::{WorktreeNameData, WorktreeNameDialog};
pub enum DialogResult<T> {
+17 -355
View File
@@ -13,8 +13,7 @@ use tui_input::backend::crossterm::EventHandler;
use tui_input::Input;
use super::DialogResult;
use crate::containers;
use crate::session::config::{load_config, update_app_state, DefaultTerminalMode, SandboxConfig};
use crate::session::config::{load_config, update_app_state};
use crate::session::profile_config::resolve_config_or_warn;
use crate::session::repo_config::HookProgress;
#[cfg(test)]
@@ -72,18 +71,6 @@ pub(super) const FIELD_HELP: &[FieldHelp] = &[
description:
"Create a git worktree (Ctrl+P to configure name, branch mode, and extra repos)",
},
FieldHelp {
name: "Sandbox",
description: "Run session in a container for isolation (Ctrl+P to configure)",
},
FieldHelp {
name: "Image",
description: "Container image. Edit config.toml [sandbox] default_image to change default",
},
FieldHelp {
name: "Environment",
description: "Env vars: bare KEY passes host value, KEY=VALUE sets explicitly",
},
FieldHelp {
name: "Group",
description: "Optional grouping for organization (Ctrl+P to browse existing groups)",
@@ -105,13 +92,7 @@ pub struct NewSessionData {
/// back to the repository's default branch. See #948.
pub base_branch: Option<String>,
pub extra_repo_paths: Vec<String>,
pub sandbox: bool,
/// The sandbox image to use (always populated from the input field).
pub sandbox_image: String,
pub yolo_mode: bool,
/// Additional environment entries for the container.
/// `KEY` = pass through from host, `KEY=VALUE` = set explicitly.
pub extra_env: Vec<String>,
/// Extra arguments to append after the agent binary
pub extra_args: String,
/// Command override for the agent binary (replaces the default binary)
@@ -151,10 +132,7 @@ impl From<NewSessionData> for crate::session::builder::InstanceParams {
worktree_branch: data.worktree_branch,
create_new_branch: data.create_new_branch,
base_branch: data.base_branch,
sandbox: data.sandbox,
sandbox_image: data.sandbox_image,
yolo_mode: data.yolo_mode,
extra_env: data.extra_env,
extra_args: data.extra_args,
command_override: data.command_override,
extra_repo_paths: data.extra_repo_paths,
@@ -189,9 +167,6 @@ pub struct NewSessionDialog {
/// overlay when "new branch" is on. Empty value = use repo
/// default. See #948.
pub(super) base_branch: Input,
pub(super) sandbox_enabled: bool,
pub(super) sandbox_image: Input,
pub(super) docker_available: bool,
pub(super) yolo_mode: bool,
pub(super) yolo_mode_default: bool,
/// Whether the session should be created in the structured (ACP) view.
@@ -201,7 +176,7 @@ pub struct NewSessionDialog {
/// session (registry entry or `agent_acp_cmd`). Recomputed whenever
/// the tool or profile changes; always false on non-serve builds.
/// Gates the Structured field's visibility, so the field-index chains
/// treat it exactly like `has_yolo` / `has_sandbox`.
/// treat it exactly like `has_yolo`.
pub(super) structured_capable: bool,
/// Additional repo paths for multi-repo workspace
pub(super) workspace_repos: Vec<String>,
@@ -221,25 +196,6 @@ pub struct NewSessionDialog {
pub(super) worktree_config_mode: bool,
/// Focused field within the worktree config overlay (0=name, 1=new_branch, 2=extra_repos)
pub(super) worktree_config_focused_field: usize,
/// Extra environment entries (session-specific).
/// `KEY` = pass through, `KEY=VALUE` = set explicitly.
pub(super) extra_env: Vec<String>,
/// True after the user edits the sandbox env list in this dialog.
/// Inherited config is shown for review, but is not submitted as a
/// per-session override unless this flips.
pub(super) extra_env_overridden: bool,
/// Whether the env list is expanded (editing mode)
pub(super) env_list_expanded: bool,
/// Currently selected index in the env list
pub(super) env_selected_index: usize,
/// Input for editing/adding env entries
pub(super) env_editing_input: Option<Input>,
/// Whether we are adding a new entry (vs editing existing)
pub(super) env_adding_new: bool,
/// Pre-computed label/value pairs for non-default inherited sandbox settings.
pub(super) inherited_settings: Vec<(String, String)>,
pub(super) sandbox_config_mode: bool,
pub(super) sandbox_focused_field: usize,
/// Tool configuration mode (Ctrl+P on tool field)
pub(super) tool_config_mode: bool,
pub(super) tool_config_focused_field: usize,
@@ -290,14 +246,11 @@ pub struct NewSessionDialog {
/// Per-field hit rect captured by the renderer of the main form
/// so a mouse click / hover can target the same cells the user
/// sees. Each entry is `(focused_field_index, rect)`. Cleared and
/// repopulated every frame; empty while an overlay (sandbox config,
/// repopulated every frame; empty while an overlay (tool config,
/// tool config, worktree config, loading, dir picker, etc.) is up,
/// so a stray click during one of those modes won't snap focus to
/// the underlying main-form field that used to sit there.
pub(super) focusable_rects: Vec<(usize, ratatui::layout::Rect)>,
/// Rects for the sandbox-config overlay, keyed by
/// `sandbox_focused_field`. Populated only when that overlay is up.
pub(super) sandbox_config_rects: Vec<(usize, ratatui::layout::Rect)>,
/// Rects for the tool-config overlay, keyed by
/// `tool_config_focused_field`.
pub(super) tool_config_rects: Vec<(usize, ratatui::layout::Rect)>,
@@ -405,36 +358,6 @@ fn compute_structured_capable(_tool: &str, _config: &crate::session::Config) ->
false
}
/// Build label/value pairs for non-default inherited sandbox settings.
fn build_inherited_settings(sandbox: &SandboxConfig) -> Vec<(String, String)> {
let mut settings = Vec::new();
if sandbox.mount_ssh {
settings.push(("Mount SSH".to_string(), "yes".to_string()));
}
if !sandbox.extra_volumes.is_empty() {
settings.push((
"Extra Volumes".to_string(),
format!("{} items", sandbox.extra_volumes.len()),
));
}
if !sandbox.volume_ignores.is_empty() {
settings.push((
"Volume Ignores".to_string(),
format!("{} items", sandbox.volume_ignores.len()),
));
}
if let Some(ref cpu) = sandbox.cpu_limit {
settings.push(("CPU Limit".to_string(), cpu.clone()));
}
if let Some(ref mem) = sandbox.memory_limit {
settings.push(("Memory Limit".to_string(), mem.clone()));
}
if sandbox.default_terminal_mode == DefaultTerminalMode::Container {
settings.push(("Terminal Mode".to_string(), "container".to_string()));
}
settings
}
impl NewSessionDialog {
pub fn new(
tools: AvailableTools,
@@ -447,7 +370,6 @@ impl NewSessionDialog {
.unwrap_or_default();
let available_tools: Vec<String> = tools.available_list().to_vec();
let docker_available = containers::get_container_runtime().is_available();
// Load resolved config (global + profile + repo overrides from cwd)
let config = crate::session::repo_config::resolve_config_with_repo_or_warn(
@@ -465,13 +387,11 @@ impl NewSessionDialog {
0
};
// Apply sandbox defaults from config (disabled for host-only agents like settl)
// Host-only agents (e.g. settl) cannot use worktrees.
let is_default_tool_host_only = available_tools
.get(tool_index)
.and_then(|t| crate::agents::get_agent(t))
.is_some_and(|a| a.host_only);
let sandbox_enabled =
docker_available && config.sandbox.enabled_by_default && !is_default_tool_host_only;
let worktree_enabled = config.worktree.enabled && !is_default_tool_host_only;
let yolo_mode = config.session.yolo_mode_default;
@@ -490,14 +410,6 @@ impl NewSessionDialog {
let command_override_value = config.session.resolve_tool_command(selected_tool);
let structured_capable = compute_structured_capable(selected_tool, &config);
// Initialize env entries and inherited settings from config when sandbox is enabled
let (extra_env, inherited_settings) = if sandbox_enabled {
let inherited = build_inherited_settings(&config.sandbox);
(config.sandbox.environment.clone(), inherited)
} else {
(Vec::new(), Vec::new())
};
let profile_index = available_profiles
.iter()
.position(|p| p == profile)
@@ -545,22 +457,10 @@ impl NewSessionDialog {
workspace_repo_dir_picker_active: false,
worktree_config_mode: false,
worktree_config_focused_field: 0,
sandbox_enabled,
sandbox_image: Input::new(config.sandbox.default_image.clone()),
docker_available,
yolo_mode,
yolo_mode_default: yolo_mode,
structured_enabled: false,
structured_capable,
extra_env,
extra_env_overridden: false,
env_list_expanded: false,
env_selected_index: 0,
env_editing_input: None,
env_adding_new: false,
inherited_settings,
sandbox_config_mode: false,
sandbox_focused_field: 0,
tool_config_mode: false,
tool_config_focused_field: 0,
extra_args: Input::new(extra_args_value),
@@ -578,7 +478,6 @@ impl NewSessionDialog {
scratch: false,
fork_seed: None,
focusable_rects: Vec::new(),
sandbox_config_rects: Vec::new(),
tool_config_rects: Vec::new(),
worktree_config_rects: Vec::new(),
}
@@ -587,9 +486,7 @@ impl NewSessionDialog {
/// Pre-fill the path field (e.g. from a selected session).
pub fn set_path(&mut self, path: String) {
self.path = Input::new(path);
if !self.extra_env_overridden {
self.reload_config_defaults();
}
self.reload_config_defaults();
}
/// Pre-fill the group field (e.g. from a selected session or group).
@@ -611,7 +508,7 @@ impl NewSessionDialog {
/// agent rather than the configured default, matching the fork seed). No-op
/// when the tool isn't in the available list. Applies the same per-tool side
/// effects as cycling the tool field: always-yolo agents force the toggle,
/// host-only agents clear sandbox and worktree, and the per-tool extra
/// host-only agents clear worktree, and the per-tool extra
/// args / command override are re-resolved.
pub fn set_tool(&mut self, tool: &str) {
let Some(index) = self.available_tools.iter().position(|t| t == tool) else {
@@ -624,7 +521,6 @@ impl NewSessionDialog {
self.yolo_mode = self.yolo_mode_default;
}
if self.selected_tool_host_only() {
self.sandbox_enabled = false;
self.worktree_enabled = false;
self.worktree_branch.reset();
}
@@ -746,15 +642,6 @@ impl NewSessionDialog {
}
}
fn refresh_inherited_sandbox_settings(&mut self) {
if !self.sandbox_enabled || self.extra_env_overridden {
return;
}
let config = self.resolve_config_for_path(&self.profile);
self.extra_env = config.sandbox.environment.clone();
self.inherited_settings = build_inherited_settings(&config.sandbox);
}
/// Whether the currently selected tool is always in YOLO mode (no opt-in needed).
fn selected_tool_always_yolo(&self) -> bool {
let tool_name = &self.available_tools[self.tool_index];
@@ -763,7 +650,7 @@ impl NewSessionDialog {
.is_some_and(|y| matches!(y, crate::agents::YoloMode::AlwaysYolo))
}
/// Whether the currently selected tool can only run on the host (no sandbox/worktree).
/// Whether the currently selected tool can only run on the host (no worktree).
fn selected_tool_host_only(&self) -> bool {
let tool_name = &self.available_tools[self.tool_index];
crate::agents::get_agent(tool_name).is_some_and(|a| a.host_only)
@@ -791,7 +678,7 @@ impl NewSessionDialog {
}
/// Re-resolve config defaults when the profile changes.
/// Resets tool, yolo, sandbox, and env settings but preserves user inputs
/// Resets tool and yolo settings but preserves user inputs
/// (title, path, group, worktree).
fn reload_config_defaults(&mut self) {
let profile = self.selected_profile().to_string();
@@ -808,27 +695,11 @@ impl NewSessionDialog {
0
};
// Reset sandbox/yolo defaults
// Reset yolo defaults
self.yolo_mode_default = config.session.yolo_mode_default;
self.yolo_mode = self.yolo_mode_default;
self.sandbox_enabled = self.docker_available
&& config.sandbox.enabled_by_default
&& !self.selected_tool_host_only();
self.worktree_enabled = config.worktree.enabled && !self.selected_tool_host_only();
// Reset sandbox image from resolved config (includes profile overrides)
self.sandbox_image = Input::new(config.sandbox.default_image.clone());
// Reset env entries and inherited settings
if self.sandbox_enabled {
self.extra_env = config.sandbox.environment.clone();
self.inherited_settings = build_inherited_settings(&config.sandbox);
} else {
self.extra_env.clear();
self.inherited_settings.clear();
}
self.extra_env_overridden = false;
// Reset extra args and command override for new default tool
let selected_tool = self
.available_tools
@@ -853,10 +724,6 @@ impl NewSessionDialog {
self.tool_config_focused_field = 0;
// Reset expanded states
self.env_list_expanded = false;
self.env_editing_input = None;
self.sandbox_config_mode = false;
self.sandbox_focused_field = 0;
self.worktree_config_mode = false;
self.worktree_config_focused_field = 0;
}
@@ -900,24 +767,12 @@ impl NewSessionDialog {
workspace_repo_dir_picker_active: false,
worktree_config_mode: false,
worktree_config_focused_field: 0,
sandbox_enabled: false,
sandbox_image: Input::new(config.sandbox.default_image.clone()),
docker_available: false,
yolo_mode: false,
yolo_mode_default: false,
// Test constructors skip config resolution, so capability is
// opted into per-test via `set_structured_capable`.
structured_enabled: false,
structured_capable: false,
extra_env: Vec::new(),
extra_env_overridden: false,
env_list_expanded: false,
env_selected_index: 0,
env_editing_input: None,
env_adding_new: false,
inherited_settings: Vec::new(),
sandbox_config_mode: false,
sandbox_focused_field: 0,
tool_config_mode: false,
tool_config_focused_field: 0,
extra_args: Input::default(),
@@ -935,7 +790,6 @@ impl NewSessionDialog {
scratch: false,
fork_seed: None,
focusable_rects: Vec::new(),
sandbox_config_rects: Vec::new(),
tool_config_rects: Vec::new(),
worktree_config_rects: Vec::new(),
}
@@ -973,24 +827,10 @@ impl NewSessionDialog {
workspace_repo_dir_picker_active: false,
worktree_config_mode: false,
worktree_config_focused_field: 0,
sandbox_enabled: false,
sandbox_image: Input::new(
containers::get_container_runtime().effective_default_image(),
),
docker_available: false,
yolo_mode: false,
yolo_mode_default: false,
structured_enabled: false,
structured_capable: false,
extra_env: Vec::new(),
extra_env_overridden: false,
env_list_expanded: false,
env_selected_index: 0,
env_editing_input: None,
env_adding_new: false,
inherited_settings: Vec::new(),
sandbox_config_mode: false,
sandbox_focused_field: 0,
tool_config_mode: false,
tool_config_focused_field: 0,
extra_args: Input::default(),
@@ -1008,7 +848,6 @@ impl NewSessionDialog {
scratch: false,
fork_seed: None,
focusable_rects: Vec::new(),
sandbox_config_rects: Vec::new(),
tool_config_rects: Vec::new(),
worktree_config_rects: Vec::new(),
}
@@ -1035,7 +874,7 @@ impl NewSessionDialog {
/// where the main-form rects don't apply (the renderer leaves
/// `focusable_rects` empty in those modes).
///
/// The overlay modes (sandbox/tool/worktree config, dir picker,
/// The overlay modes (tool/worktree config, dir picker,
/// group/branch/projects picker, help) are still keyboard-only;
/// `handle_click` only fires on the top-level form.
pub fn handle_click(&mut self, col: u16, row: u16) -> Option<DialogResult<NewSessionData>> {
@@ -1094,20 +933,9 @@ impl NewSessionDialog {
}
}
// Config overlays (sandbox / tool / worktree) take precedence
// Config overlays (tool / worktree) take precedence
// over the main form: their rects are populated only while
// their mode is active.
if self.sandbox_config_mode {
if let Some(hit) = self
.sandbox_config_rects
.iter()
.find(|(_, rect)| rect.contains(pos))
.map(|(f, _)| *f)
{
self.sandbox_focused_field = hit;
}
return Some(DialogResult::Continue);
}
if self.tool_config_mode {
if let Some(hit) = self
.tool_config_rects
@@ -1175,7 +1003,6 @@ impl NewSessionDialog {
let has_profile_selection = self.available_profiles.len() > 1;
let has_tool_selection = self.available_tools.len() > 1;
let is_host_only = self.selected_tool_host_only();
let has_sandbox = self.docker_available && !is_host_only;
let has_yolo = !self.selected_tool_always_yolo();
let has_structured = self.structured_capable;
let profile_field = if has_profile_selection { 0 } else { usize::MAX };
@@ -1202,26 +1029,12 @@ impl NewSessionDialog {
} else {
usize::MAX
};
let worktree_field = if !is_host_only {
let f = fi;
fi += 1;
f
} else {
usize::MAX
};
let sandbox_field = if has_sandbox {
// No `fi += 1` here; sandbox is the last index we need to
// resolve, and the unused assignment trips clippy.
fi
} else {
usize::MAX
};
let worktree_field = if !is_host_only { fi } else { usize::MAX };
if self.focused_field == profile_field {
if self.available_profiles.len() > 1 {
self.profile_index = (self.profile_index + 1) % self.available_profiles.len();
// Mirror the keyboard cycle: pick up the new profile's
// defaults (sandbox, yolo, hooks, tool override) so the
// defaults (yolo, hooks, tool override) so the
// dialog reflects what a submit would actually create.
self.reload_config_defaults();
}
@@ -1230,7 +1043,7 @@ impl NewSessionDialog {
self.tool_index = (self.tool_index + 1) % self.available_tools.len();
// Same side effects the Space/Left/Right tool handler
// applies: always-yolo tools force the toggle, host-only
// tools clear sandbox + worktree, and the per-tool config
// tools clear worktree, and the per-tool config
// overlay (and its branch override) get re-resolved.
if self.selected_tool_always_yolo() {
self.yolo_mode = true;
@@ -1238,7 +1051,6 @@ impl NewSessionDialog {
self.yolo_mode = self.yolo_mode_default;
}
if self.selected_tool_host_only() {
self.sandbox_enabled = false;
self.worktree_enabled = false;
self.worktree_branch.reset();
}
@@ -1264,21 +1076,6 @@ impl NewSessionDialog {
self.worktree_config_mode = false;
}
}
} else if self.focused_field == sandbox_field {
self.sandbox_enabled = !self.sandbox_enabled;
if self.sandbox_enabled {
let config = self.resolve_config_for_path(&self.profile);
self.extra_env = config.sandbox.environment.clone();
self.inherited_settings = build_inherited_settings(&config.sandbox);
self.extra_env_overridden = false;
} else {
self.extra_env.clear();
self.extra_env_overridden = false;
self.env_list_expanded = false;
self.env_editing_input = None;
self.inherited_settings.clear();
self.sandbox_config_mode = false;
}
}
// Path / Title / Group: focus change only, no toggle action.
}
@@ -1300,11 +1097,6 @@ impl NewSessionDialog {
return DialogResult::Continue;
}
// Delegate to sandbox config mode handler when active
if self.sandbox_config_mode {
return self.handle_sandbox_config_key(key);
}
// Delegate to tool config mode handler when active
if self.tool_config_mode {
return self.handle_tool_config_key(key);
@@ -1377,13 +1169,11 @@ impl NewSessionDialog {
let has_profile_selection = self.available_profiles.len() > 1;
let has_tool_selection = self.available_tools.len() > 1;
let is_host_only = self.selected_tool_host_only();
let has_sandbox = self.docker_available && !is_host_only;
let has_yolo = !self.selected_tool_always_yolo();
let has_structured = self.structured_capable;
// Field order: [profile], path, title, [tool], [structured], [yolo], worktree, [sandbox], group
// Field order: [profile], path, title, [tool], [structured], [yolo], worktree, group
// Worktree sub-options (new_branch, extra_repos) are in a Ctrl+P overlay.
// Tool config (extra_args, command_override) is in a Ctrl+P overlay on tool field.
// Sandbox sub-options are in a separate sandbox_config_mode overlay.
let profile_field = if has_profile_selection { 0 } else { usize::MAX };
let mut fi = if has_profile_selection { 1 } else { 0 }; // next field index
fi += 2; // title + path
@@ -1415,13 +1205,6 @@ impl NewSessionDialog {
} else {
usize::MAX
};
let sandbox_field = if has_sandbox {
let f = fi;
fi += 1;
f
} else {
usize::MAX
};
let group_field = fi;
fi += 1;
let max_field = fi;
@@ -1453,12 +1236,6 @@ impl NewSessionDialog {
self.error_message = None;
return DialogResult::Continue;
}
if self.focused_field == sandbox_field && self.sandbox_enabled {
self.refresh_inherited_sandbox_settings();
self.sandbox_config_mode = true;
self.sandbox_focused_field = 0;
return DialogResult::Continue;
}
}
if self.handle_path_shortcuts(key) {
@@ -1568,7 +1345,6 @@ impl NewSessionDialog {
self.yolo_mode = self.yolo_mode_default;
}
if self.selected_tool_host_only() {
self.sandbox_enabled = false;
self.worktree_enabled = false;
self.worktree_branch.reset();
}
@@ -1596,25 +1372,6 @@ impl NewSessionDialog {
}
DialogResult::Continue
}
KeyCode::Left | KeyCode::Right | KeyCode::Char(' ')
if self.focused_field == sandbox_field =>
{
self.sandbox_enabled = !self.sandbox_enabled;
if self.sandbox_enabled {
let config = self.resolve_config_for_path(&self.profile);
self.extra_env = config.sandbox.environment.clone();
self.inherited_settings = build_inherited_settings(&config.sandbox);
self.extra_env_overridden = false;
} else {
self.extra_env.clear();
self.extra_env_overridden = false;
self.env_list_expanded = false;
self.env_editing_input = None;
self.inherited_settings.clear();
self.sandbox_config_mode = false;
}
DialogResult::Continue
}
KeyCode::Left | KeyCode::Right | KeyCode::Char(' ')
if self.focused_field == yolo_mode_field =>
{
@@ -1631,7 +1388,6 @@ impl NewSessionDialog {
if self.focused_field != profile_field
&& self.focused_field != tool_field
&& self.focused_field != worktree_field
&& self.focused_field != sandbox_field
&& self.focused_field != yolo_mode_field
&& self.focused_field != structured_field
{
@@ -1651,59 +1407,6 @@ impl NewSessionDialog {
}
}
/// Handle key events when in sandbox configuration mode.
fn handle_sandbox_config_key(&mut self, key: KeyEvent) -> DialogResult<NewSessionData> {
// Sandbox config fields: 0=image, 1=env (inherited is always-visible, not focusable)
const SANDBOX_IMAGE: usize = 0;
const SANDBOX_ENV: usize = 1;
const SANDBOX_MAX: usize = 2;
// Handle env list editing when expanded
if self.env_list_expanded && self.sandbox_focused_field == SANDBOX_ENV {
return self.handle_env_list_key(key);
}
match key.code {
KeyCode::Esc => {
self.sandbox_config_mode = false;
DialogResult::Continue
}
KeyCode::Char('?') => {
self.show_help = true;
DialogResult::Continue
}
KeyCode::Enter if self.sandbox_focused_field == SANDBOX_ENV => {
self.env_list_expanded = true;
self.env_selected_index = 0;
DialogResult::Continue
}
KeyCode::Enter => {
self.sandbox_config_mode = false;
DialogResult::Continue
}
KeyCode::Tab | KeyCode::Down => {
self.sandbox_focused_field = (self.sandbox_focused_field + 1) % SANDBOX_MAX;
DialogResult::Continue
}
KeyCode::BackTab | KeyCode::Up => {
self.sandbox_focused_field = if self.sandbox_focused_field == 0 {
SANDBOX_MAX - 1
} else {
self.sandbox_focused_field - 1
};
DialogResult::Continue
}
_ => {
// Text input for image field only
if self.sandbox_focused_field == SANDBOX_IMAGE {
self.sandbox_image
.handle_event(&crossterm::event::Event::Key(key));
}
DialogResult::Continue
}
}
}
/// Handle key events when in tool configuration mode. `?` opens the help
/// overlay (new-session only); everything else delegates to the shared
/// tool-config component.
@@ -1911,33 +1614,6 @@ impl NewSessionDialog {
}
}
/// Handle key events when the env list is expanded
fn handle_env_list_key(&mut self, key: KeyEvent) -> DialogResult<NewSessionData> {
let validate =
|value: &str, list: &[String]| !value.is_empty() && !list.contains(&value.to_string());
let snapshot: Vec<String> = self.extra_env.clone();
let result = handle_editable_list_key(
key,
&mut self.extra_env,
&mut self.env_list_expanded,
&mut self.env_selected_index,
&mut self.env_editing_input,
&mut self.env_adding_new,
validate,
);
// Validate the current entry if the list changed
if self.extra_env != snapshot {
self.extra_env_overridden = true;
self.error_message = self
.extra_env
.get(self.env_selected_index)
.and_then(|entry| crate::session::validate_env_entry(entry));
}
result
}
/// Handle key events when the workspace repos list is expanded
fn handle_workspace_repos_list_key(&mut self, key: KeyEvent) -> DialogResult<NewSessionData> {
// When actively editing a repo path, handle path-specific keys first
@@ -2088,7 +1764,7 @@ impl NewSessionDialog {
let base = if self.has_profile_selection() { 1 } else { 0 };
let is_host_only = self.selected_tool_host_only();
// Field layout: [profile], title, path, [tool], [structured], [yolo], [worktree], [sandbox], group
// Field layout: [profile], title, path, [tool], [structured], [yolo], [worktree], group
let mut fi = base + 2 + if has_tool_selection { 1 } else { 0 };
if self.structured_capable {
fi += 1; // structured checkbox
@@ -2099,9 +1775,6 @@ impl NewSessionDialog {
if !is_host_only {
fi += 1; // worktree checkbox
}
if self.docker_available && !is_host_only {
fi += 1; // sandbox checkbox
}
let group_field = fi;
let path_field = self.path_field();
@@ -2116,9 +1789,7 @@ impl NewSessionDialog {
pub fn handle_paste(&mut self, text: &str) {
// Route to the active sub-mode input if one is open
let target: &mut Input = if let Some(ref mut input) = self.env_editing_input {
input
} else if let Some(ref mut input) = self.workspace_repo_editing_input {
let target: &mut Input = if let Some(ref mut input) = self.workspace_repo_editing_input {
input
} else if self.tool_config_mode {
if self.tool_config_focused_field == 0 {
@@ -2128,8 +1799,6 @@ impl NewSessionDialog {
}
} else if self.worktree_config_mode && self.worktree_config_focused_field == 0 {
&mut self.worktree_branch
} else if self.sandbox_config_mode && self.sandbox_focused_field == 0 {
&mut self.sandbox_image
} else {
self.current_input_mut()
};
@@ -2198,14 +1867,7 @@ impl NewSessionDialog {
} else {
Vec::new()
},
sandbox: self.sandbox_enabled,
sandbox_image: self.sandbox_image.value().trim().to_string(),
yolo_mode: self.yolo_mode || self.selected_tool_always_yolo(),
extra_env: if self.sandbox_enabled && self.extra_env_overridden {
self.extra_env.clone()
} else {
Vec::new()
},
extra_args: self.extra_args.value().trim().to_string(),
command_override: self.command_override.value().trim().to_string(),
scratch: self.scratch,
+3 -325
View File
@@ -31,12 +31,6 @@ impl NewSessionDialog {
return;
}
// If in sandbox config mode, render that overlay instead
if self.sandbox_config_mode {
self.render_sandbox_config(frame, area, theme);
return;
}
// If in tool config mode, render that overlay instead
if self.tool_config_mode {
self.render_tool_config(frame, area, theme);
@@ -52,7 +46,6 @@ impl NewSessionDialog {
let has_profile_selection = self.has_profile_selection();
let has_tool_selection = self.available_tools.len() > 1;
let is_host_only = self.selected_tool_host_only();
let has_sandbox = self.docker_available && !is_host_only;
let has_yolo = !self.selected_tool_always_yolo();
let has_structured = self.structured_capable;
let dialog_width = 80;
@@ -93,9 +86,6 @@ impl NewSessionDialog {
if !is_host_only {
constraints.push(Constraint::Length(2)); // Worktree Branch
}
if has_sandbox {
constraints.push(Constraint::Length(2)); // Sandbox checkbox (summary only)
}
constraints.push(Constraint::Length(2)); // Group (always, at the bottom)
// For errors, calculate how many lines we need based on the text length.
@@ -174,13 +164,6 @@ impl NewSessionDialog {
} else {
usize::MAX
};
let sandbox_field = if has_sandbox {
let f = fi;
fi += 1;
f
} else {
usize::MAX
};
let group_field = fi;
// Profile picker (only when multiple profiles)
@@ -378,49 +361,6 @@ impl NewSessionDialog {
ci += 1;
}
// Sandbox checkbox with summary (only when a container runtime is available)
if has_sandbox {
let is_sandbox_focused = self.focused_field == sandbox_field;
let sandbox_label_style = if is_sandbox_focused {
Style::default().fg(theme.accent).underlined()
} else {
Style::default().fg(theme.text)
};
let checkbox = if self.sandbox_enabled { "[x]" } else { "[ ]" };
let checkbox_style = if self.sandbox_enabled {
Style::default().fg(theme.accent).bold()
} else {
Style::default().fg(theme.dimmed)
};
let mut spans = vec![
Span::styled("Sandbox:", sandbox_label_style),
Span::raw(" "),
Span::styled(checkbox, checkbox_style),
Span::styled(
" Run in container",
if self.sandbox_enabled {
Style::default().fg(theme.accent)
} else {
Style::default().fg(theme.dimmed)
},
),
];
if self.sandbox_enabled {
spans.push(Span::styled(
" (Ctrl+P to configure)",
Style::default().fg(theme.dimmed),
));
}
let area = chunks[ci];
frame.render_widget(Paragraph::new(Line::from(spans)), area);
self.focusable_rects.push((sandbox_field, area));
ci += 1;
}
// Group (always visible, at the bottom before hints)
let group_placeholder =
if !self.existing_groups.is_empty() && self.focused_field == group_field {
@@ -692,95 +632,6 @@ impl NewSessionDialog {
set_prefixed_input_cursor_position(frame, row_area, prefix, input);
}
fn render_sandbox_config(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
self.sandbox_config_rects.clear();
let dialog_width: u16 = 72;
// Sandbox config fields: image, env, inherited
let env_list_height: u16 = if self.env_list_expanded {
(2 + self.extra_env.len() as u16).clamp(4, 8)
} else {
2
};
let inherited_height: u16 = 2 + self.inherited_settings.len().max(1) as u16;
let constraints = vec![
Constraint::Length(2), // Image
Constraint::Length(env_list_height), // Environment
Constraint::Length(inherited_height), // Inherited settings
Constraint::Min(1), // Hints
];
let fields_height: u16 = constraints
.iter()
.map(|c| match c {
Constraint::Length(n) => *n,
Constraint::Min(n) => *n,
_ => 0,
})
.sum();
let dialog_height = fields_height + 4;
let dialog_area = crate::tui::dialogs::centered_rect(area, dialog_width, dialog_height);
frame.render_widget(Clear, dialog_area);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.accent))
.title(" Sandbox Configuration ")
.title_style(Style::default().fg(theme.title).bold());
let inner = block.inner(dialog_area);
frame.render_widget(block, dialog_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints(constraints)
.split(inner);
let mut ci = 0;
// Image field
render_text_field(
frame,
chunks[ci],
"Image:",
&self.sandbox_image,
self.sandbox_focused_field == 0,
None,
theme,
);
self.sandbox_config_rects.push((0, chunks[ci]));
ci += 1;
// Environment
self.render_env_field(frame, chunks[ci], self.sandbox_focused_field == 1, theme);
self.sandbox_config_rects.push((1, chunks[ci]));
ci += 1;
// Inherited settings (always visible, not focusable)
self.render_inherited_field(frame, chunks[ci], theme);
ci += 1;
// Hints
let hint_spans = vec![
Span::styled("Tab", Style::default().fg(theme.hint)),
Span::raw(" next "),
Span::styled("Enter", Style::default().fg(theme.hint)),
Span::raw(" edit "),
Span::styled("Esc", Style::default().fg(theme.hint)),
Span::raw(" back"),
];
frame.render_widget(Paragraph::new(Line::from(hint_spans)), chunks[ci]);
if self.show_help {
self.render_help_overlay(frame, area, theme);
}
}
fn render_tool_config(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
let selected_tool = self
.available_tools
@@ -990,126 +841,6 @@ impl NewSessionDialog {
}
}
fn render_env_field(&self, frame: &mut Frame, area: Rect, is_focused: bool, theme: &Theme) {
let label_style = if is_focused {
Style::default().fg(theme.accent).underlined()
} else {
Style::default().fg(theme.text)
};
if !self.env_list_expanded {
// Collapsed view
let count = self.extra_env.len();
let summary = if count == 0 {
"(empty - press Enter to add)".to_string()
} else {
format!("[{} items]", count)
};
let summary_style = if count > 0 {
Style::default().fg(theme.accent)
} else {
Style::default().fg(theme.dimmed)
};
let line = Line::from(vec![
Span::styled("Environment:", label_style),
Span::raw(" "),
Span::styled(summary, summary_style),
]);
frame.render_widget(Paragraph::new(line), area);
} else {
// Expanded view with list
let mut lines: Vec<Line> = Vec::new();
let mut cursor_row: Option<(usize, &'static str, &Input)> = None;
// Header with controls hint
let header = Line::from(vec![
Span::styled("Environment:", label_style),
Span::styled(
" (a)dd (d)el (Enter)edit (Esc)close",
Style::default().fg(theme.dimmed),
),
]);
lines.push(header);
// Check if we're in editing/adding mode
if let Some(ref input) = self.env_editing_input {
if self.env_adding_new {
// Show existing items
for (i, entry) in self.extra_env.iter().enumerate() {
let prefix = if i == self.env_selected_index {
" > "
} else {
" "
};
lines.push(Line::from(Span::styled(
format!("{}{}", prefix, entry),
Style::default().fg(theme.text),
)));
}
// Show input for new item
let input_line = Line::from(vec![
Span::styled(" + ", Style::default().fg(theme.accent)),
Span::styled(input.value(), Style::default().fg(theme.accent).bold()),
Span::styled("_", Style::default().fg(theme.accent)),
]);
lines.push(input_line);
cursor_row = Some((lines.len() - 1, " + ", input));
} else {
// Editing existing item
for (i, entry) in self.extra_env.iter().enumerate() {
if i == self.env_selected_index {
// Show editable input
let input_line = Line::from(vec![
Span::styled(" > ", Style::default().fg(theme.accent)),
Span::styled(
input.value(),
Style::default().fg(theme.accent).bold(),
),
Span::styled("_", Style::default().fg(theme.accent)),
]);
lines.push(input_line);
cursor_row = Some((lines.len() - 1, " > ", input));
} else {
let prefix = " ";
lines.push(Line::from(Span::styled(
format!("{}{}", prefix, entry),
Style::default().fg(theme.text),
)));
}
}
}
} else {
// Normal list display
if self.extra_env.is_empty() {
lines.push(Line::from(Span::styled(
" (press 'a' to add KEY or KEY=VALUE)",
Style::default().fg(theme.dimmed),
)));
} else {
for (i, entry) in self.extra_env.iter().enumerate() {
let is_selected = i == self.env_selected_index;
let prefix = if is_selected { " > " } else { " " };
let style = if is_selected {
Style::default().fg(theme.accent).bold()
} else {
Style::default().fg(theme.text)
};
lines.push(Line::from(Span::styled(
format!("{}{}", prefix, entry),
style,
)));
}
}
}
frame.render_widget(Paragraph::new(lines), area);
if let Some((row, prefix, input)) = cursor_row {
Self::set_input_cursor_on_row(frame, area, row, prefix, input);
}
}
}
fn render_extra_repos_field(
&self,
frame: &mut Frame,
@@ -1259,46 +990,15 @@ impl NewSessionDialog {
}
}
fn render_inherited_field(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
let label_style = Style::default().fg(theme.dimmed);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(Span::styled("Inherited Settings:", label_style)));
if self.inherited_settings.is_empty() {
lines.push(Line::from(Span::styled(
" (all defaults)",
Style::default().fg(theme.dimmed),
)));
} else {
for (label, value) in &self.inherited_settings {
lines.push(Line::from(vec![
Span::styled(
format!(" {}: ", label),
Style::default().fg(theme.dimmed),
),
Span::styled(value.as_str(), Style::default().fg(theme.accent)),
]));
}
}
frame.render_widget(Paragraph::new(lines), area);
}
fn render_help_overlay(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
let has_tool_selection = self.available_tools.len() > 1;
let has_sandbox = self.docker_available;
let show_sandbox_options_help = has_sandbox && self.sandbox_enabled;
let dialog_width: u16 = HELP_DIALOG_WIDTH;
let has_profile_selection = self.has_profile_selection();
// Base fields: Scratch, Title, Path, YOLO, Worktree, Group + close hint
let base_height: u16 = 20;
let dialog_height: u16 = base_height
+ if has_profile_selection { 3 } else { 0 }
+ if has_tool_selection { 3 } else { 0 }
+ if has_sandbox { 3 } else { 0 }
+ if show_sandbox_options_help { 12 } else { 0 };
+ if has_tool_selection { 3 } else { 0 };
let dialog_area = crate::tui::dialogs::centered_rect(area, dialog_width, dialog_height);
@@ -1323,8 +1023,6 @@ impl NewSessionDialog {
"Profile" => has_profile_selection,
"Tool" => has_tool_selection,
"YOLO Mode" => !self.selected_tool_always_yolo(),
"Sandbox" => has_sandbox,
"Image" | "Environment" => show_sandbox_options_help,
_ => true,
};
if !show {
@@ -1353,21 +1051,12 @@ impl NewSessionDialog {
}
fn render_loading(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
let needs_extra_line = self.sandbox_enabled;
let show_hook_output = self.has_hooks;
let max_output_lines: usize = 6;
let dialog_width: u16 = if show_hook_output {
70
} else if needs_extra_line {
55
} else {
50
};
let dialog_width: u16 = if show_hook_output { 70 } else { 50 };
let dialog_height: u16 = if show_hook_output {
(6 + max_output_lines as u16).min(area.height)
} else if needs_extra_line {
9
} else {
7
};
@@ -1451,11 +1140,7 @@ impl NewSessionDialog {
frame.render_widget(Paragraph::new(lines), inner);
} else {
let loading_text = if self.sandbox_enabled {
"Setting up sandbox..."
} else {
"Creating session..."
};
let loading_text = "Creating session...";
let mut lines = vec![
Line::from(""),
@@ -1468,13 +1153,6 @@ impl NewSessionDialog {
]),
];
if needs_extra_line {
lines.push(Line::from(Span::styled(
" (first time may take a few minutes)",
Style::default().fg(theme.dimmed),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" Press ", Style::default().fg(theme.dimmed)),
-507
View File
@@ -612,12 +612,6 @@ fn test_new_branch_field_hidden_without_worktree() {
assert_eq!(dialog.focused_field, 0);
}
#[test]
fn test_sandbox_disabled_by_default() {
let dialog = multi_tool_dialog();
assert!(!dialog.sandbox_enabled);
}
#[test]
fn test_worktree_disabled_by_default() {
let dialog = multi_tool_dialog();
@@ -635,17 +629,6 @@ fn test_worktree_enabled_from_config() {
assert!(dialog.worktree_enabled);
}
#[test]
fn test_sandbox_image_from_config() {
let mut config = Config::default();
config.sandbox.default_image = "my-custom-sandbox:local".to_string();
let dialog =
NewSessionDialog::new_with_config(vec!["claude"], "/tmp/project".to_string(), config);
assert_eq!(dialog.sandbox_image.value(), "my-custom-sandbox:local");
}
#[test]
fn test_worktree_toggle_submit_without_name() {
let mut dialog = single_tool_dialog();
@@ -690,221 +673,12 @@ fn test_worktree_config_name_field_sets_branch_override() {
}
}
#[test]
fn test_sandbox_image_initialized_with_effective_default() {
use crate::containers;
let dialog = multi_tool_dialog();
assert_eq!(
dialog.sandbox_image.value(),
containers::get_container_runtime().effective_default_image()
);
}
#[test]
fn test_tab_skips_sandbox_options_in_main_form() {
let mut dialog = multi_tool_dialog();
dialog.docker_available = true;
dialog.sandbox_enabled = true;
// With sandbox enabled, sandbox sub-options are in separate mode now.
// Main form (single profile): title(0), path(1), tool(2), yolo(3), worktree(4), sandbox(5), group(6)
for _ in 0..5 {
dialog.handle_key(key(KeyCode::Tab));
}
assert_eq!(dialog.focused_field, 5); // sandbox field
dialog.handle_key(key(KeyCode::Tab));
assert_eq!(dialog.focused_field, 6); // group field (no sandbox sub-options inline)
dialog.handle_key(key(KeyCode::Tab));
assert_eq!(dialog.focused_field, 0); // wrap to start
}
#[test]
fn test_tab_skips_sandbox_when_disabled() {
let mut dialog = multi_tool_dialog();
dialog.docker_available = true;
dialog.sandbox_enabled = false;
// Single profile: title(0), path(1), tool(2), yolo(3), worktree(4), sandbox(5), group(6)
for _ in 0..5 {
dialog.handle_key(key(KeyCode::Tab));
}
assert_eq!(dialog.focused_field, 5); // sandbox field
dialog.handle_key(key(KeyCode::Tab));
assert_eq!(dialog.focused_field, 6); // group field
dialog.handle_key(key(KeyCode::Tab));
assert_eq!(dialog.focused_field, 0); // wrap to start
}
#[test]
fn test_submit_with_custom_sandbox_image() {
let mut dialog = multi_tool_dialog();
dialog.docker_available = true;
dialog.sandbox_enabled = true;
dialog.sandbox_image = Input::new("custom/image:tag".to_string());
dialog.title = Input::new("Test".to_string());
let result = dialog.handle_key(key(KeyCode::Enter));
match result {
DialogResult::Submit(data) => {
assert!(data.sandbox);
assert_eq!(data.sandbox_image, "custom/image:tag");
}
_ => panic!("Expected Submit"),
}
}
#[test]
fn test_submit_with_default_image_passes_through() {
use crate::containers;
let mut dialog = multi_tool_dialog();
dialog.docker_available = true;
dialog.sandbox_enabled = true;
dialog.title = Input::new("Test".to_string());
let result = dialog.handle_key(key(KeyCode::Enter));
match result {
DialogResult::Submit(data) => {
assert!(data.sandbox);
assert_eq!(
data.sandbox_image,
containers::get_container_runtime().effective_default_image()
);
}
_ => panic!("Expected Submit"),
}
}
#[test]
fn test_submit_with_empty_image() {
let mut dialog = multi_tool_dialog();
dialog.docker_available = true;
dialog.sandbox_enabled = true;
dialog.sandbox_image = Input::new("".to_string());
dialog.title = Input::new("Test".to_string());
let result = dialog.handle_key(key(KeyCode::Enter));
match result {
DialogResult::Submit(data) => {
assert!(data.sandbox);
assert_eq!(data.sandbox_image, "");
}
_ => panic!("Expected Submit"),
}
}
#[test]
fn test_submit_sandbox_image_always_included() {
let mut dialog = multi_tool_dialog();
dialog.docker_available = true;
dialog.sandbox_enabled = false;
dialog.sandbox_image = Input::new("custom/image:tag".to_string());
dialog.title = Input::new("Test".to_string());
let result = dialog.handle_key(key(KeyCode::Enter));
match result {
DialogResult::Submit(data) => {
assert!(!data.sandbox);
assert_eq!(data.sandbox_image, "custom/image:tag");
}
_ => panic!("Expected Submit"),
}
}
#[test]
fn test_sandbox_image_input_in_config_mode() {
use crate::containers;
let mut dialog = multi_tool_dialog();
dialog.docker_available = true;
dialog.sandbox_enabled = true;
dialog.sandbox_config_mode = true;
dialog.sandbox_focused_field = 0; // image field
dialog.handle_key(key(KeyCode::Char('a')));
dialog.handle_key(key(KeyCode::Char('b')));
dialog.handle_key(key(KeyCode::Char('c')));
let expected = format!(
"{}abc",
containers::get_container_runtime().effective_default_image()
);
assert_eq!(dialog.sandbox_image.value(), expected);
}
#[test]
fn test_yolo_mode_disabled_by_default() {
let dialog = multi_tool_dialog();
assert!(!dialog.yolo_mode);
}
#[test]
fn test_yolo_mode_toggle() {
let mut dialog = multi_tool_dialog();
dialog.docker_available = true;
dialog.sandbox_enabled = true;
dialog.focused_field = 3; // yolo mode field (single profile: path=0, title=1, tool=2, yolo=3)
assert!(!dialog.yolo_mode);
dialog.handle_key(key(KeyCode::Char(' ')));
assert!(dialog.yolo_mode);
dialog.handle_key(key(KeyCode::Char(' ')));
assert!(!dialog.yolo_mode);
}
#[test]
fn test_submit_with_yolo_mode_enabled() {
let mut dialog = multi_tool_dialog();
dialog.docker_available = true;
dialog.sandbox_enabled = true;
dialog.yolo_mode = true;
dialog.title = Input::new("Test".to_string());
let result = dialog.handle_key(key(KeyCode::Enter));
match result {
DialogResult::Submit(data) => {
assert!(data.sandbox);
assert!(data.yolo_mode);
}
_ => panic!("Expected Submit"),
}
}
#[test]
fn test_yolo_independent_of_sandbox() {
let mut dialog = multi_tool_dialog();
dialog.docker_available = true;
dialog.sandbox_enabled = false;
dialog.yolo_mode = true;
dialog.title = Input::new("Test".to_string());
let result = dialog.handle_key(key(KeyCode::Enter));
match result {
DialogResult::Submit(data) => {
assert!(!data.sandbox);
assert!(data.yolo_mode);
}
_ => panic!("Expected Submit"),
}
}
#[test]
fn test_disabling_sandbox_does_not_reset_yolo_mode() {
let mut dialog = multi_tool_dialog();
dialog.docker_available = true;
dialog.sandbox_enabled = true;
dialog.yolo_mode = true;
// sandbox field (single profile): title=0, path=1, tool=2, yolo=3, worktree=4, sandbox=5
dialog.focused_field = 5;
dialog.handle_key(key(KeyCode::Char(' ')));
assert!(!dialog.sandbox_enabled);
assert!(dialog.yolo_mode);
}
#[test]
fn help_content_fits_in_dialog() {
const BORDER_WIDTH: u16 = 2;
@@ -1155,289 +929,8 @@ fn test_profile_included_in_submit() {
}
}
#[test]
#[serial_test::serial]
fn test_profile_switch_reloads_sandbox_env_without_session_override() {
let temp_home = tempfile::tempdir().expect("temp home");
// Both guards route through the shared env lock (`isolate_home` owns it;
// the second is a same-thread re-entrant acquisition) and restore their
// keys on Drop, so nothing leaks into sibling tests.
let _home = crate::session::test_support::isolate_home(temp_home.path());
let _extra_env =
crate::session::test_support::EnvGuard::set(&[("OLD_THING", "1"), ("NEW_THING", "2")]);
let app_dir = crate::session::get_app_dir().expect("app dir");
let profiles_dir = app_dir.join("profiles");
fs::create_dir_all(profiles_dir.join("default")).expect("default profile");
fs::create_dir_all(profiles_dir.join("aoe")).expect("aoe profile");
fs::write(
app_dir.join("config.toml"),
r#"
default_profile = "default"
[sandbox]
enabled_by_default = true
environment = ["THING=$OLD_THING"]
"#,
)
.expect("global config");
fs::write(
profiles_dir.join("aoe").join("config.toml"),
r#"
[sandbox]
environment = ["THING=$NEW_THING"]
"#,
)
.expect("profile config");
let mut dialog = single_tool_dialog();
dialog.available_profiles = vec!["default".to_string(), "aoe".to_string()];
dialog.profile_descriptions = vec![None, None];
dialog.profile_index = 0;
dialog.docker_available = true;
dialog.reload_config_defaults();
assert_eq!(dialog.extra_env, vec!["THING=$OLD_THING".to_string()]);
dialog.focused_field = 0;
dialog.handle_key(key(KeyCode::Right));
assert_eq!(dialog.selected_profile(), "aoe");
assert!(dialog.sandbox_enabled);
assert_eq!(dialog.extra_env, vec!["THING=$NEW_THING".to_string()]);
assert!(!dialog.extra_env_overridden);
match dialog.build_submit_result() {
DialogResult::Submit(data) => {
assert_eq!(data.profile, "aoe");
assert!(
data.extra_env.is_empty(),
"inherited sandbox env must not become a session override"
);
}
_ => panic!("Expected Submit"),
}
}
#[test]
#[serial_test::serial]
fn test_set_path_reloads_repo_sandbox_env_without_session_override() {
let temp_home = tempfile::tempdir().expect("temp home");
// `isolate_home` restores HOME/XDG on Drop and holds the shared env lock
// for the test body.
let _home = crate::session::test_support::isolate_home(temp_home.path());
let app_dir = crate::session::get_app_dir().expect("app dir");
let profiles_dir = app_dir.join("profiles");
fs::create_dir_all(profiles_dir.join("default")).expect("default profile");
fs::write(
app_dir.join("config.toml"),
r#"
default_profile = "default"
[sandbox]
enabled_by_default = true
environment = ["THING=$OLD_THING"]
"#,
)
.expect("global config");
let repo = tempfile::tempdir().expect("repo dir");
fs::create_dir_all(repo.path().join(".agent-of-empires")).expect("repo config dir");
fs::write(
repo.path().join(".agent-of-empires/config.toml"),
r#"
[sandbox]
environment = ["THING=$REPO_THING"]
"#,
)
.expect("repo config");
let mut dialog = single_tool_dialog();
dialog.docker_available = true;
dialog.reload_config_defaults();
assert_eq!(dialog.extra_env, vec!["THING=$OLD_THING".to_string()]);
dialog.set_path(repo.path().to_string_lossy().to_string());
assert_eq!(dialog.extra_env, vec!["THING=$REPO_THING".to_string()]);
assert!(!dialog.extra_env_overridden);
match dialog.build_submit_result() {
DialogResult::Submit(data) => {
assert!(
data.extra_env.is_empty(),
"repo-inherited sandbox env must not become a session override"
);
}
_ => panic!("Expected Submit"),
}
}
#[test]
#[serial_test::serial]
fn test_sandbox_config_refreshes_typed_path_repo_env_without_session_override() {
let temp_home = tempfile::tempdir().expect("temp home");
// `isolate_home` restores HOME/XDG on Drop and holds the shared env lock
// for the test body.
let _home = crate::session::test_support::isolate_home(temp_home.path());
let app_dir = crate::session::get_app_dir().expect("app dir");
let profiles_dir = app_dir.join("profiles");
fs::create_dir_all(profiles_dir.join("default")).expect("default profile");
fs::write(
app_dir.join("config.toml"),
r#"
default_profile = "default"
[sandbox]
enabled_by_default = true
environment = ["THING=$OLD_THING"]
"#,
)
.expect("global config");
let repo = tempfile::tempdir().expect("repo dir");
fs::create_dir_all(repo.path().join(".agent-of-empires")).expect("repo config dir");
fs::write(
repo.path().join(".agent-of-empires/config.toml"),
r#"
[sandbox]
environment = ["THING=$REPO_THING"]
"#,
)
.expect("repo config");
let mut dialog = single_tool_dialog();
dialog.docker_available = true;
dialog.reload_config_defaults();
assert_eq!(dialog.extra_env, vec!["THING=$OLD_THING".to_string()]);
dialog.path = Input::new(repo.path().to_string_lossy().to_string());
// Field order (no profile picker, single tool): path 0, title 1, then
// the Structured row only when the serve build makes claude
// ACP-capable (reload_config_defaults recomputed it), then yolo,
// worktree, sandbox. Derive the offset so both feature builds target
// the sandbox row.
dialog.focused_field = 4 + usize::from(dialog.structured_capable);
let result = dialog.handle_key(ctrl_key(KeyCode::Char('p')));
assert!(matches!(result, DialogResult::Continue));
assert!(dialog.sandbox_config_mode);
assert_eq!(dialog.extra_env, vec!["THING=$REPO_THING".to_string()]);
assert!(!dialog.extra_env_overridden);
match dialog.build_submit_result() {
DialogResult::Submit(data) => {
assert!(
data.extra_env.is_empty(),
"repo-inherited sandbox env must not become a session override"
);
}
_ => panic!("Expected Submit"),
}
}
#[test]
fn test_env_list_edit_submits_session_override() {
let mut dialog = single_tool_dialog();
dialog.docker_available = true;
dialog.sandbox_enabled = true;
dialog.extra_env = vec!["THING=$NEW_THING".to_string()];
dialog.env_list_expanded = true;
dialog.sandbox_config_mode = true;
dialog.sandbox_focused_field = 1;
dialog.handle_env_list_key(key(KeyCode::Char('a')));
for ch in "EXTRA=1".chars() {
dialog.handle_env_list_key(key(KeyCode::Char(ch)));
}
dialog.handle_env_list_key(key(KeyCode::Enter));
assert!(dialog.extra_env_overridden);
match dialog.build_submit_result() {
DialogResult::Submit(data) => {
assert_eq!(
data.extra_env,
vec!["THING=$NEW_THING".to_string(), "EXTRA=1".to_string()]
);
}
_ => panic!("Expected Submit"),
}
}
// --- Sandbox config mode tests ---
#[test]
fn test_ctrl_p_on_sandbox_enters_config_mode() {
let mut dialog = multi_tool_dialog();
dialog.docker_available = true;
dialog.sandbox_enabled = true;
// sandbox field (single profile): title=0, path=1, tool=2, yolo=3, worktree=4, sandbox=5
dialog.focused_field = 5;
let result = dialog.handle_key(ctrl_key(KeyCode::Char('p')));
assert!(matches!(result, DialogResult::Continue));
assert!(dialog.sandbox_config_mode);
assert_eq!(dialog.sandbox_focused_field, 0);
}
#[test]
fn test_enter_on_sandbox_submits() {
let mut dialog = multi_tool_dialog();
dialog.docker_available = true;
dialog.sandbox_enabled = true;
dialog.focused_field = 6; // sandbox field
let result = dialog.handle_key(key(KeyCode::Enter));
// Enter should submit, not enter config mode
assert!(!dialog.sandbox_config_mode);
assert!(matches!(result, DialogResult::Submit(_)));
}
#[test]
fn test_ctrl_p_on_disabled_sandbox_does_not_open_config() {
let mut dialog = multi_tool_dialog();
dialog.docker_available = true;
dialog.sandbox_enabled = false;
dialog.focused_field = 6; // sandbox field
dialog.handle_key(ctrl_key(KeyCode::Char('p')));
assert!(!dialog.sandbox_config_mode);
}
#[test]
fn test_sandbox_config_mode_esc_returns_to_main() {
let mut dialog = multi_tool_dialog();
dialog.sandbox_config_mode = true;
dialog.sandbox_focused_field = 1;
let result = dialog.handle_key(key(KeyCode::Esc));
assert!(matches!(result, DialogResult::Continue));
assert!(!dialog.sandbox_config_mode);
}
#[test]
fn test_sandbox_config_mode_tab_cycles() {
let mut dialog = multi_tool_dialog();
dialog.sandbox_config_mode = true;
dialog.sandbox_focused_field = 0;
dialog.handle_key(key(KeyCode::Tab));
assert_eq!(dialog.sandbox_focused_field, 1);
dialog.handle_key(key(KeyCode::Tab));
assert_eq!(dialog.sandbox_focused_field, 0); // wrap
}
#[test]
fn test_sandbox_config_mode_enter_on_image_returns_to_main() {
let mut dialog = multi_tool_dialog();
dialog.sandbox_config_mode = true;
dialog.sandbox_focused_field = 0; // image
let result = dialog.handle_key(key(KeyCode::Enter));
assert!(matches!(result, DialogResult::Continue));
assert!(!dialog.sandbox_config_mode);
}
#[test]
fn test_scratch_toggle_with_ctrl_t() {
let mut dialog = single_tool_dialog();
+6 -40
View File
@@ -1,20 +1,15 @@
//! Serve dialog: drives the `aoe serve --daemon` lifecycle (bound on
//! 0.0.0.0 for LAN/VPN access) and shows a QR + URL + log tail so a phone
//! can connect. The TUI is a controller here, not a host: it spawns the
//! daemon, reads `$APP_DIR/serve.{pid,url,log}` files, and runs
//! 0.0.0.0 for LAN/VPN access) and shows the URL + log tail so another
//! device can connect. The TUI is a controller here, not a host: it spawns
//! the daemon, reads `$APP_DIR/serve.{pid,url,log}` files, and runs
//! `aoe serve --stop` to tear down. The daemon survives across TUI quits,
//! just like tmux sessions or the CLI-invoked daemon path.
//!
//! Only compiled with the `serve` feature, since the qrcode crate it needs
//! lives there.
#![cfg(feature = "serve")]
use std::path::PathBuf;
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyEvent};
use qrcode::render::unicode::Dense1x2;
use qrcode::QrCode;
use rand::RngExt;
use ratatui::prelude::*;
use ratatui::widgets::*;
@@ -55,7 +50,7 @@ pub enum ServeViewState {
/// Daemon is live. No child field -- the TUI does not own it.
Active {
urls: Vec<ServeUrl>,
/// Which `urls` entry is the primary QR target. Starts at 0.
/// Which `urls` entry is the primary displayed URL. Starts at 0.
/// Tab advances; cycles; no-op when urls.len() <= 1.
url_index: usize,
opened_at: Instant,
@@ -500,7 +495,7 @@ fn spawn_daemon() -> Result<(), String> {
cmd.args(["serve", "--daemon", "--port", &port.to_string()]);
// 0.0.0.0 makes the server reachable on every local interface
// (Tailscale, LAN, loopback). The server-side serve.url writer picks
// Tailscale > LAN > localhost as the primary URL in the QR.
// Tailscale > LAN > localhost as the primary displayed URL.
cmd.args(["--host", "0.0.0.0"]);
cmd.stdin(std::process::Stdio::null())
// The daemon path forks; the child's tracing + stdio land in the
@@ -716,19 +711,6 @@ fn render_active(
let url = &active_url.url;
let kind_label = active_url.label.as_deref();
let qr_text = match QrCode::new(url.as_bytes()) {
Ok(code) => code
.render::<Dense1x2>()
.quiet_zone(true)
.dark_color(Dense1x2::Dark)
.light_color(Dense1x2::Light)
.build(),
Err(_) => String::from("(QR unavailable; use the URL below)"),
};
let qr_lines: Vec<&str> = qr_text.lines().collect();
let qr_height = qr_lines.len() as u16;
let full_url = url.as_str();
let url_prefix = "URL: ";
let full_url_len = url_prefix.chars().count() + full_url.chars().count();
@@ -783,7 +765,7 @@ fn render_active(
let show_split_token = !url_fits_one_line && split_token.is_some();
// Calculate total content height for vertical centering.
let mut inner_height: u16 = qr_height + 1 /* spacer */ + 1 /* url */;
let mut inner_height: u16 = 1 /* url */;
if show_kind_label {
inner_height += 1;
}
@@ -794,8 +776,6 @@ fn render_active(
let v_pad = content_area.height.saturating_sub(inner_height) / 2;
let mut constraints = vec![Constraint::Length(v_pad)]; // top padding
constraints.push(Constraint::Length(qr_height));
constraints.push(Constraint::Length(1)); // spacer after QR
if show_kind_label {
constraints.push(Constraint::Length(1));
}
@@ -814,20 +794,6 @@ fn render_active(
let mut idx: usize = 0;
idx += 1; // top padding
// QR code
let qr_widget: Vec<Line> = qr_lines
.iter()
.map(|l| Line::from(Span::styled(*l, Style::default().fg(theme.text))))
.collect();
frame.render_widget(
Paragraph::new(qr_widget).alignment(Alignment::Center),
chunks[idx],
);
idx += 1;
// Spacer after QR
idx += 1;
if let Some(label) = kind_label {
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
-194
View File
@@ -1,194 +0,0 @@
//! Tool picker dialog: quick list of configured tool sessions.
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::prelude::*;
use ratatui::widgets::*;
use super::{centered_rect, DialogResult};
use crate::session::config::ToolSessionConfig;
use crate::tui::styles::Theme;
pub struct ToolPickerDialog {
items: Vec<ToolPickerEntry>,
cursor: usize,
dialog_area: Rect,
list_area: Rect,
}
struct ToolPickerEntry {
name: String,
command: String,
hotkey: String,
background: bool,
}
impl ToolPickerDialog {
pub fn new(tools: &std::collections::HashMap<String, ToolSessionConfig>) -> Self {
let mut items: Vec<ToolPickerEntry> = tools
.iter()
.map(|(name, config)| ToolPickerEntry {
name: name.clone(),
command: config.command.clone(),
hotkey: config.hotkey.clone().unwrap_or_default(),
background: config.background,
})
.collect();
items.sort_by(|a, b| a.name.cmp(&b.name));
Self {
items,
cursor: 0,
dialog_area: Rect::default(),
list_area: Rect::default(),
}
}
fn row_to_idx(&self, col: u16, row: u16) -> Option<usize> {
let pos = ratatui::layout::Position::from((col, row));
if !self.list_area.contains(pos) {
return None;
}
let row_in_list = (row - self.list_area.y) as usize;
if row_in_list >= self.items.len() {
return None;
}
Some(row_in_list)
}
pub fn handle_click(&mut self, col: u16, row: u16) -> DialogResult<String> {
if !self
.dialog_area
.contains(ratatui::layout::Position::from((col, row)))
{
return DialogResult::Cancel;
}
let Some(idx) = self.row_to_idx(col, row) else {
return DialogResult::Continue;
};
self.cursor = idx;
DialogResult::Submit(self.items[idx].name.clone())
}
pub fn handle_hover(&mut self, col: u16, row: u16) -> bool {
let Some(idx) = self.row_to_idx(col, row) else {
return false;
};
if self.cursor == idx {
return false;
}
self.cursor = idx;
true
}
pub fn handle_key(&mut self, key: KeyEvent) -> DialogResult<String> {
match key.code {
KeyCode::Esc | KeyCode::Char(';') => DialogResult::Cancel,
KeyCode::Enter => {
if let Some(entry) = self.items.get(self.cursor) {
DialogResult::Submit(entry.name.clone())
} else {
DialogResult::Cancel
}
}
KeyCode::Up | KeyCode::Char('k') => {
if self.cursor > 0 {
self.cursor -= 1;
}
DialogResult::Continue
}
KeyCode::Down | KeyCode::Char('j') => {
if self.cursor + 1 < self.items.len() {
self.cursor += 1;
}
DialogResult::Continue
}
KeyCode::Home => {
self.cursor = 0;
DialogResult::Continue
}
KeyCode::End => {
self.cursor = self.items.len().saturating_sub(1);
DialogResult::Continue
}
_ => DialogResult::Continue,
}
}
pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
let width = 50u16.min(area.width.saturating_sub(4));
// +2 for borders, +1 for the footer hint row.
let height = (self.items.len() as u16 + 3).min(area.height.saturating_sub(4));
let dialog_area = centered_rect(area, width, height);
self.dialog_area = dialog_area;
frame.render_widget(Clear, dialog_area);
let block = Block::default()
.title(" Tool Sessions ")
.title_style(Style::default().fg(theme.title))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.accent))
.style(Style::default().bg(theme.background));
let inner = block.inner(dialog_area);
frame.render_widget(block, dialog_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(inner);
let list_area = chunks[0];
let footer_area = chunks[1];
self.list_area = list_area;
let items: Vec<ListItem> = self
.items
.iter()
.enumerate()
.map(|(i, entry)| {
let hotkey_part = if entry.hotkey.is_empty() {
String::new()
} else {
format!(" [{}]", entry.hotkey)
};
let background_part = if entry.background { " [bg]" } else { "" };
let line = Line::from(vec![
Span::styled(
&entry.name,
Style::default().fg(if i == self.cursor {
theme.accent
} else {
theme.text
}),
),
Span::styled(background_part, Style::default().fg(theme.hint)),
Span::styled(
format!(" {}", entry.command),
Style::default().fg(theme.dimmed),
),
Span::styled(hotkey_part, Style::default().fg(theme.hint)),
]);
ListItem::new(line)
})
.collect();
let list = List::new(items).highlight_style(
Style::default()
.bg(theme.selection)
.add_modifier(Modifier::BOLD),
);
let mut state = ListState::default();
state.select(Some(self.cursor));
frame.render_stateful_widget(list, list_area, &mut state);
let footer = Line::from(vec![
Span::styled("↑↓", Style::default().fg(theme.hint)),
Span::raw(" navigate "),
Span::styled("Enter", Style::default().fg(theme.hint)),
Span::raw(" open "),
Span::styled("Esc", Style::default().fg(theme.hint)),
Span::raw(" close"),
]);
frame.render_widget(Paragraph::new(footer), footer_area);
}
}
-41
View File
@@ -22,7 +22,6 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::ViewMode;
use crate::session::config::SortOrder;
use crate::tui::dialogs::PaletteGroup;
@@ -31,7 +30,6 @@ use crate::tui::dialogs::PaletteGroup;
pub enum ActionId {
Quit,
Help,
ToolPicker,
SearchStart,
SearchNext,
NewSession,
@@ -56,7 +54,6 @@ pub enum ActionId {
/// unread -> read). Gated behind the `session.unread_indicator` config
/// toggle (on by default); a no-op when disabled.
ToggleUnread,
ToggleContainer,
TogglePreviewInfo,
/// Toggle the system diagnostics strip (live CPU and memory pressure plus
/// running agent/process counts). Persisted via
@@ -116,7 +113,6 @@ const fn f(n: u8) -> Chord {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Context {
Always,
TerminalView,
/// Favorites are actionable: either the Attention sort is active, where
/// favorite is a within-tier tiebreak, or `session.favorites_first` is on,
/// where it pins in every sort order. With neither, toggling a favorite
@@ -164,7 +160,6 @@ pub struct Binding {
/// Runtime state the guards consult.
pub struct Ctx {
pub view_mode: ViewMode,
pub sort_order: SortOrder,
pub has_search: bool,
/// True when the cursor sits on a real project header in project view, so
@@ -183,7 +178,6 @@ fn chord_matches(c: &Chord, key: &KeyEvent) -> bool {
fn context_holds(context: Context, ctx: &Ctx) -> bool {
match context {
Context::Always => true,
Context::TerminalView => ctx.view_mode == ViewMode::Terminal,
Context::FavoritesUsable => {
ctx.sort_order == SortOrder::Attention || crate::session::favorites_first()
}
@@ -258,18 +252,6 @@ pub static BINDINGS: &[Binding] = &[
serve_only: false,
}),
},
// --- terminal-view only ---
Binding {
id: ActionId::ToggleContainer,
non_strict: &[k('c')],
strict: &[k('C')],
context: Context::TerminalView,
help: Some(HelpMeta {
section: HelpSection::Views,
desc: "Toggle container/host (sandbox)",
}),
palette: None,
},
// --- always-on actions ---
Binding {
id: ActionId::Quit,
@@ -324,17 +306,6 @@ pub static BINDINGS: &[Binding] = &[
serve_only: false,
}),
},
Binding {
id: ActionId::ToolPicker,
non_strict: &[k(';')],
strict: &[k(';')],
context: Context::Always,
help: Some(HelpMeta {
section: HelpSection::Actions,
desc: "Open tool session",
}),
palette: None,
},
Binding {
id: ActionId::SearchStart,
non_strict: &[k('/')],
@@ -795,10 +766,8 @@ pub fn palette_id(id: ActionId) -> &'static str {
ActionId::Help => "help",
ActionId::NextWaiting => "next-waiting",
ActionId::Quit => "quit",
ActionId::ToolPicker => "tool-picker",
ActionId::SearchStart => "search",
ActionId::SearchNext => "search-next",
ActionId::ToggleContainer => "toggle-container",
ActionId::ToggleProjectPin => "toggle-project-pin",
ActionId::Fork => "fork",
ActionId::AutoName => "auto-name",
@@ -811,7 +780,6 @@ mod tests {
fn ctx() -> Ctx {
Ctx {
view_mode: ViewMode::Structured,
sort_order: SortOrder::Newest,
has_search: false,
project_group_selected: false,
@@ -1010,15 +978,6 @@ mod tests {
);
crate::session::set_favorites_first(original);
// Container toggle only resolves in Terminal view.
let mut c = ctx();
assert_eq!(resolve(&key('c'), false, &c), None);
c.view_mode = ViewMode::Terminal;
assert_eq!(
resolve(&key('c'), false, &c),
Some(ActionId::ToggleContainer)
);
}
#[test]
+38 -862
View File
File diff suppressed because it is too large Load Diff
-7
View File
@@ -311,11 +311,6 @@ pub(in crate::tui) enum LiveSendTarget {
Agent,
/// The paired host-shell terminal pane.
Terminal,
/// The paired container-shell terminal pane (sandboxed sessions
/// in container terminal mode).
ContainerTerminal,
/// A named tool's paired tmux pane.
Tool(String),
}
/// Format a display label for a `(title, target)` pair so the compose
@@ -327,8 +322,6 @@ pub(in crate::tui) fn format_target_label(title: &str, target: &LiveSendTarget)
match target {
LiveSendTarget::Agent => title.to_string(),
LiveSendTarget::Terminal => format!("{title} (terminal)"),
LiveSendTarget::ContainerTerminal => format!("{title} (container)"),
LiveSendTarget::Tool(name) => format!("{title} ({name})"),
}
}
+6 -260
View File
@@ -26,7 +26,7 @@ use crate::session::{
append_archived_section, append_archived_section_by_project, append_trash_section,
config::{load_config, update_app_state, update_config, GroupByMode, SortOrder},
flatten_sessions_by_attention, flatten_tree, flatten_tree_all_profiles, resolve_config_or_warn,
DefaultTerminalMode, EnsureReadyOutcome, Group, GroupTree, Instance, Item, Storage,
EnsureReadyOutcome, Group, GroupTree, Instance, Item, Storage,
};
use crate::tmux::AvailableTools;
@@ -283,16 +283,6 @@ pub enum ViewMode {
#[default]
Structured,
Terminal,
/// Previewing a tool session (lazygit, yazi, etc.)
Tool(String),
}
/// Terminal mode for sandboxed sessions (container vs host)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TerminalMode {
#[default]
Host,
Container,
}
/// Cached preview content to avoid subprocess calls on every frame
@@ -554,8 +544,6 @@ pub struct HomeView {
/// snapshot expansion (#2045). Reuses [`ConfirmDialog`] with a
/// "don't warn me again" checkbox persisted to app_state.
pub(super) volume_ignores_glob_dialog: Option<ConfirmDialog>,
/// Session data pending the volume_ignores glob expansion acknowledgment.
pub(super) pending_volume_ignores_glob_data: Option<NewSessionData>,
pub(super) no_agents_dialog: Option<NoAgentsDialog>,
pub(super) info_dialog: Option<InfoDialog>,
pub(super) profile_picker_dialog: Option<ProfilePickerDialog>,
@@ -671,15 +659,7 @@ pub struct HomeView {
/// dialog is accepted. Carries the session id and which terminal (host vs
/// container) the row was showing, so the accept path kills exactly the
/// terminal the user was looking at without touching the agent session.
pub(super) pending_stop_terminal: Option<(String, TerminalMode)>,
/// Tool session to kill after the Tool-view "kill tool" confirm dialog is
/// accepted: session id plus the tool name the view was previewing. Same
/// contract as `pending_stop_terminal`, the agent session is untouched.
pub(super) pending_stop_tool: Option<(String, String)>,
/// Sandbox image to pull after the "image update available" confirm dialog
/// is accepted. Carries the image through the generic `ConfirmDialog`,
/// which only knows its action string.
pub(super) pending_image_pull: Option<String>,
pub(super) pending_stop_terminal: Option<String>,
/// Session whose persisted view flips (structured ↔ terminal) after the
/// switch-view confirm dialog is accepted.
pub(super) pending_switch_view_session: Option<String>,
@@ -784,8 +764,6 @@ pub struct HomeView {
// Performance: preview caching
pub(super) preview_cache: PreviewCache,
pub(super) terminal_preview_cache: PreviewCache,
pub(super) container_terminal_preview_cache: PreviewCache,
pub(super) tool_preview_cache: PreviewCache,
/// Per-frame timing of the preview pipeline's two latency-sensitive
/// phases, reset by `App::render` before each `render` and populated
@@ -904,10 +882,7 @@ pub struct HomeView {
pub(super) manual_unread_hold: Option<String>,
// Terminal mode for sandboxed sessions (per-session, ephemeral)
pub(super) terminal_modes: HashMap<String, TerminalMode>,
// Default terminal mode from config
pub(super) default_terminal_mode: TerminalMode,
pub(super) status_hook_config: crate::status_hooks::StatusHookConfig,
pub(super) status_hook_configs: HashMap<String, crate::status_hooks::StatusHookConfig>,
@@ -1049,19 +1024,6 @@ pub struct HomeView {
/// calls and tear down the still-booting tmux pane.
pub(super) restart_cooldown_at: std::collections::HashMap<String, std::time::Instant>,
// Tool sessions config (lazygit, yazi, etc.)
pub(super) tool_configs: HashMap<String, crate::session::config::ToolSessionConfig>,
/// Pre-parsed and sorted view of valid tool hotkeys: (name, KeyCode, KeyModifiers).
/// Built once at construction and on settings reload, then iterated on every
/// keystroke to look up matching tools. Sorted by name so the alphabetically-first
/// tool wins on duplicate hotkeys.
pub(super) tool_hotkey_cache: Vec<(
String,
crossterm::event::KeyCode,
crossterm::event::KeyModifiers,
)>,
pub(super) tool_picker_dialog: Option<super::dialogs::ToolPickerDialog>,
/// Process-wide file-watch primitive. Threaded into per-profile
/// `Storage` instances so writes from this process surface
/// immediately via the in-process Local fast path, and used to
@@ -2052,10 +2014,6 @@ impl HomeView {
.clone()
.unwrap_or_else(crate::session::config::resolve_default_profile);
let resolved = resolve_config_or_warn(&config_profile);
let default_terminal_mode = match resolved.sandbox.default_terminal_mode {
DefaultTerminalMode::Host => TerminalMode::Host,
DefaultTerminalMode::Container => TerminalMode::Container,
};
let status_hook_configs = Self::load_status_hook_configs(Self::status_hook_profile_names(
active_profile.as_deref(),
&storages,
@@ -2153,7 +2111,6 @@ impl HomeView {
hooks_install_dialog: None,
pending_hooks_install_data: None,
volume_ignores_glob_dialog: None,
pending_volume_ignores_glob_data: None,
no_agents_dialog: None,
info_dialog: None,
profile_picker_dialog: None,
@@ -2192,8 +2149,6 @@ impl HomeView {
pending_attach_after_warning: None,
pending_stop_session: None,
pending_stop_terminal: None,
pending_stop_tool: None,
pending_image_pull: None,
pending_switch_view_session: None,
#[cfg(feature = "serve")]
pending_daemon_start_session: None,
@@ -2237,8 +2192,6 @@ impl HomeView {
preview_cache: PreviewCache::default(),
preview_timings: PreviewTimings::default(),
terminal_preview_cache: PreviewCache::default(),
container_terminal_preview_cache: PreviewCache::default(),
tool_preview_cache: PreviewCache::default(),
preview_scroll_offset: 0,
preview_text_view: PreviewTextView::default(),
preview_area: Rect::default(),
@@ -2253,8 +2206,6 @@ impl HomeView {
last_preview_click: None,
unread_dwell: None,
manual_unread_hold: None,
terminal_modes: HashMap::new(),
default_terminal_mode,
status_hook_config,
status_hook_configs,
strict_hotkeys,
@@ -2290,12 +2241,6 @@ impl HomeView {
recovery_lock: None,
recovery_in_flight: std::collections::HashSet::new(),
restart_cooldown_at: std::collections::HashMap::new(),
tool_configs: user_config
.as_ref()
.map(|c| c.tools.clone())
.unwrap_or_default(),
tool_hotkey_cache: Vec::new(),
tool_picker_dialog: None,
file_watch,
disk_watch,
config_watch,
@@ -2305,15 +2250,6 @@ impl HomeView {
pending_watcher_theme: None,
};
view.tool_hotkey_cache = input::build_tool_hotkey_cache(&view.tool_configs);
let hotkey_warnings = input::validate_tool_hotkeys(&view.tool_configs);
if !hotkey_warnings.is_empty() && view.info_dialog.is_none() {
view.info_dialog = Some(InfoDialog::new(
"Tool hotkey config errors",
&hotkey_warnings.join("\n"),
));
}
// Clean up orphaned Creating instances from a prior crash
let orphan_ids: Vec<String> = view
.instances
@@ -4307,23 +4243,6 @@ impl HomeView {
if let Err(e) = self.reload() {
tracing::warn!(target: "tui.home", "Failed to reload session state: {e}");
}
// The creation poller may have minted `before_start_env` while
// bringing the container up. It is `#[serde(skip)]`, so the
// reload above dropped it; carry it back onto the live instance
// (mirroring the CLI's `merge_post_start` and the structured-view
// stamp-back) so the agent launch reuses it instead of re-minting.
let minted = instance
.sandbox_info
.as_mut()
.map(|sb| std::mem::take(&mut sb.before_start_env))
.unwrap_or_default();
if !minted.is_empty() {
self.mutate_instance(&session_id, |inst| {
if let Some(sb) = inst.sandbox_info.as_mut() {
sb.before_start_env = minted.clone();
}
});
}
// reload()'s restore-previous-selection fallback lands
// the cursor on whichever flat_items index is closest
// to the now-removed stub, which in project-grouped
@@ -4603,7 +4522,6 @@ impl HomeView {
|| self.project_session_picker_dialog.is_some()
|| self.projects_dialog.is_some()
|| self.command_palette.is_some()
|| self.tool_picker_dialog.is_some()
|| self.send_message_dialog.is_some()
|| self.permission_response_dialog.is_some()
|| serve_open
@@ -4660,7 +4578,6 @@ impl HomeView {
|| self.project_session_picker_dialog.is_some()
|| self.projects_dialog.is_some()
|| self.command_palette.is_some()
|| self.tool_picker_dialog.is_some()
|| self.send_message_dialog.is_some()
|| self.permission_response_dialog.is_some()
|| serve_open
@@ -5289,8 +5206,6 @@ impl HomeView {
// Invalidate preview caches since the visible sessions changed
self.preview_cache = PreviewCache::default();
self.terminal_preview_cache = PreviewCache::default();
self.container_terminal_preview_cache = PreviewCache::default();
self.tool_preview_cache = PreviewCache::default();
self.preview_scroll_offset = 0;
// Clear search since match indices are invalid with new flat_items
if self.search_active {
@@ -5466,25 +5381,6 @@ impl HomeView {
return;
}
}
live_send::LiveSendTarget::ContainerTerminal => {
if let Err(e) = self.ensure_container_terminal_pane_ready(session_id, boot_size) {
self.info_dialog = Some(InfoDialog::new(
"Send Failed",
&format!("Cannot prepare container terminal: {}", e),
));
return;
}
}
live_send::LiveSendTarget::Tool(name) => {
let name = name.clone();
if let Err(e) = self.ensure_tool_pane_ready(session_id, &name, boot_size) {
self.info_dialog = Some(InfoDialog::new(
"Send Failed",
&format!("Cannot prepare tool '{}': {}", name, e),
));
return;
}
}
};
let Some(inst) = self.get_instance(session_id) else {
self.info_dialog = Some(InfoDialog::new(
@@ -5509,21 +5405,13 @@ impl HomeView {
live_send::LiveSendTarget::Terminal => crate::tmux::Session::from_name(
&crate::tmux::TerminalSession::resolve_name(&inst.id, &inst.title),
),
live_send::LiveSendTarget::ContainerTerminal => crate::tmux::Session::from_name(
&crate::tmux::ContainerTerminalSession::resolve_name(&inst.id, &inst.title),
),
live_send::LiveSendTarget::Tool(name) => crate::tmux::Session::from_name(
crate::tmux::ToolSession::new(&inst.id, &inst.title, name).session_name(),
),
};
// Agent gets a tool-specific Enter delay so paste-burst-aware
// agents (e.g. Codex) don't swallow the final Enter. Shells in
// the paired terminal panes don't need the delay.
let delay = match &target {
live_send::LiveSendTarget::Agent => crate::agents::send_keys_enter_delay(&inst.tool),
live_send::LiveSendTarget::Terminal
| live_send::LiveSendTarget::ContainerTerminal
| live_send::LiveSendTarget::Tool(_) => 0,
live_send::LiveSendTarget::Terminal => 0,
};
if let Err(e) = tmux_session.send_keys_with_delay(message, delay) {
self.info_dialog = Some(InfoDialog::new(
@@ -5635,7 +5523,7 @@ impl HomeView {
}
/// Whether the target pane is already live, so its entry can skip the
/// transient revive toast. Terminal and tool targets check their own pane's
/// transient revive toast. Terminal targets check their own pane's
/// existence and ignore the agent's status, because a stopped agent can
/// have a live paired terminal.
fn target_pane_is_warm(&self, session_id: &str, target: &live_send::LiveSendTarget) -> bool {
@@ -5647,14 +5535,6 @@ impl HomeView {
live_send::LiveSendTarget::Terminal => {
crate::tmux::TerminalSession::resolve_name(&inst.id, &inst.title)
}
live_send::LiveSendTarget::ContainerTerminal => {
crate::tmux::ContainerTerminalSession::resolve_name(&inst.id, &inst.title)
}
live_send::LiveSendTarget::Tool(name) => {
crate::tmux::ToolSession::new(&inst.id, &inst.title, name)
.session_name()
.to_string()
}
};
crate::tmux::Session::from_name(&tmux_name).exists()
}
@@ -5756,25 +5636,6 @@ impl HomeView {
return Err(());
}
}
live_send::LiveSendTarget::ContainerTerminal => {
if let Err(e) = self.ensure_container_terminal_pane_ready(session_id, boot_size) {
self.info_dialog = Some(InfoDialog::new(
"Live send failed",
&format!("Cannot prepare container terminal: {}", e),
));
return Err(());
}
}
live_send::LiveSendTarget::Tool(name) => {
let name = name.clone();
if let Err(e) = self.ensure_tool_pane_ready(session_id, &name, boot_size) {
self.info_dialog = Some(InfoDialog::new(
"Live send failed",
&format!("Cannot prepare tool '{}': {}", name, e),
));
return Err(());
}
}
};
let inst = match self.get_instance(session_id) {
Some(inst) => inst.clone(),
@@ -5808,14 +5669,6 @@ impl HomeView {
live_send::LiveSendTarget::Terminal => {
crate::tmux::TerminalSession::resolve_name(&inst.id, &inst.title)
}
live_send::LiveSendTarget::ContainerTerminal => {
crate::tmux::ContainerTerminalSession::resolve_name(&inst.id, &inst.title)
}
live_send::LiveSendTarget::Tool(name) => {
crate::tmux::ToolSession::new(&inst.id, &inst.title, name)
.session_name()
.to_string()
}
};
// Switching live mode from session A to session B (click on a
// different row while already live): we need to drop the old
@@ -5842,12 +5695,10 @@ impl HomeView {
// (The synchronous path got this for free via its cross-session
// kill-switch branch; the worker path applies content lazily,
// so clear it explicitly here.) All targets are cleared because
// a live-send switch can retarget to Terminal / ContainerTerminal
// too, and the view can be flipped to any of them right after.
// a live-send switch can retarget to Terminal too, and the view
// can be flipped to either of them right after.
self.preview_cache = PreviewCache::default();
self.terminal_preview_cache = PreviewCache::default();
self.container_terminal_preview_cache = PreviewCache::default();
self.tool_preview_cache = PreviewCache::default();
if let Some(name) = &prev_tmux_name {
crate::tmux::Session::from_name(name).reset_size_to_latest_client();
}
@@ -6662,68 +6513,6 @@ impl HomeView {
Ok(())
}
/// Container-shell counterpart of `ensure_terminal_pane_ready`,
/// used when the live-send target is the container terminal
/// (sandboxed sessions in container terminal mode).
fn ensure_container_terminal_pane_ready(
&mut self,
session_id: &str,
size: Option<(u16, u16)>,
) -> anyhow::Result<()> {
let inst = self
.get_instance(session_id)
.ok_or_else(|| anyhow::anyhow!("session not found: {}", session_id))?
.clone();
if !inst.is_sandboxed() {
anyhow::bail!("Cannot prepare container terminal for non-sandboxed session");
}
let term = inst.container_terminal_tmux_session()?;
if !term.exists() || term.is_pane_dead() {
if term.exists() {
let _ = term.kill();
}
self.start_container_terminal_for_instance_with_size(session_id, size)?;
}
Ok(())
}
/// Tool-pane counterpart of `ensure_terminal_pane_ready`: mirrors
/// `App::attach_tool_session`'s on-demand creation so live-send can
/// target a tool (lazygit, yazi, etc.) that hasn't been launched yet.
/// Used by `prepare_live_send` when the live target is `Tool(name)`.
fn ensure_tool_pane_ready(
&mut self,
session_id: &str,
tool_name: &str,
size: Option<(u16, u16)>,
) -> anyhow::Result<()> {
let inst = self
.get_instance(session_id)
.ok_or_else(|| anyhow::anyhow!("session not found: {}", session_id))?
.clone();
let tool_config = self
.tool_configs
.get(tool_name)
.ok_or_else(|| anyhow::anyhow!("tool '{}' is not configured", tool_name))?
.clone();
if tool_config.command.is_empty() {
anyhow::bail!("Tool '{}' has no command configured", tool_name);
}
let tool = crate::tmux::ToolSession::new(&inst.id, &inst.title, tool_name);
if !tool.exists() || tool.is_pane_dead() {
if tool.exists() {
let _ = tool.kill();
}
tool.create_with_size(
&inst.project_path,
&tool_config.command,
size,
&inst.effective_profile(),
)?;
}
Ok(())
}
pub fn restart_instance_with_size_opts(
&mut self,
id: &str,
@@ -6778,14 +6567,6 @@ impl HomeView {
}
}
/// Get the terminal mode for a session (uses config default if not set)
pub fn get_terminal_mode(&self, session_id: &str) -> TerminalMode {
self.terminal_modes
.get(session_id)
.copied()
.unwrap_or(self.default_terminal_mode)
}
/// The profile whose config the view should resolve. The active profile
/// when one is selected, otherwise (all-profiles mode) the user's default
/// profile. Never an empty string and never a hard-coded name.
@@ -7087,10 +6868,6 @@ impl HomeView {
config: crate::session::Config,
origin: ConfigRefreshOrigin,
) {
self.default_terminal_mode = match config.sandbox.default_terminal_mode {
DefaultTerminalMode::Host => TerminalMode::Host,
DefaultTerminalMode::Container => TerminalMode::Container,
};
self.status_hook_config = config.status_hooks.clone();
self.refresh_status_hook_config_cache();
self.strict_hotkeys = config.session.strict_hotkeys;
@@ -7113,18 +6890,6 @@ impl HomeView {
crate::tui::styles::idle_decay_window(config.theme.idle_decay_minutes);
crate::session::set_unread_enabled(config.session.unread_indicator);
crate::session::set_favorites_first(config.session.favorites_first);
self.tool_configs = config.tools;
self.tool_hotkey_cache = input::build_tool_hotkey_cache(&self.tool_configs);
let hotkey_warnings = input::validate_tool_hotkeys(&self.tool_configs);
if matches!(origin, ConfigRefreshOrigin::Interactive)
&& !hotkey_warnings.is_empty()
&& self.info_dialog.is_none()
{
self.info_dialog = Some(InfoDialog::new(
"Tool hotkey config errors",
&hotkey_warnings.join("\n"),
));
}
// Watcher path: stash for tick-loop dispatch (App owns theme state).
// Reads via `resolve_theme_name` (global-only by contract), not
// `config.theme.name` which would carry a stale per-profile override.
@@ -7227,25 +6992,6 @@ impl HomeView {
self.status_hook_config = status_hooks.clone();
}
}
/// Toggle terminal mode between Container and Host for a session
pub fn toggle_terminal_mode(&mut self, session_id: &str) {
let current = self.get_terminal_mode(session_id);
let new_mode = match current {
TerminalMode::Container => TerminalMode::Host,
TerminalMode::Host => TerminalMode::Container,
};
self.terminal_modes.insert(session_id.to_string(), new_mode);
}
pub fn start_container_terminal_for_instance_with_size(
&mut self,
id: &str,
size: Option<(u16, u16)>,
) -> anyhow::Result<()> {
self.try_mutate_instance(id, |inst| inst.start_container_terminal_with_size(size))
.map(|_| ())
}
}
#[cfg(test)]
+10 -213
View File
@@ -28,39 +28,6 @@ fn group_membership<'a>(
}
}
/// Why a tied-worktree rename must refuse to move the worktree directory.
///
/// `git worktree move` does a `rename(2)` on the worktree dir, which the
/// kernel refuses while anything holds it. Two distinct holders matter and
/// they need different wording: an active agent (the session's `status`),
/// and a sandbox session's container, which bind-mounts the worktree dir and
/// stays alive on `sleep infinity` even while the agent is Idle. Both are
/// cleared by stopping the session.
#[derive(Debug, PartialEq, Eq)]
enum WorktreeRenameBlock {
/// The session's agent is busy (running, starting, etc.).
ActiveAgent,
/// A sandbox container is running and mounting the worktree dir.
SandboxContainer,
}
/// Decide whether a tied-worktree rename must be blocked, and why. Status
/// takes precedence so a busy agent reports as `ActiveAgent` rather than
/// reaching for the container reason. Returns `None` when the move is safe.
fn worktree_rename_block(
status: Status,
is_sandboxed: bool,
container_running: bool,
) -> Option<WorktreeRenameBlock> {
if status.blocks_worktree_edit() {
Some(WorktreeRenameBlock::ActiveAgent)
} else if is_sandboxed && container_running {
Some(WorktreeRenameBlock::SandboxContainer)
} else {
None
}
}
impl HomeView {
/// Pin or unpin the project header under the cursor (project view only).
///
@@ -583,7 +550,6 @@ impl HomeView {
instance: inst.clone(),
delete_worktree: options.delete_worktree,
delete_branch: options.delete_branch,
delete_sandbox: options.delete_sandbox,
force_delete: options.force_delete,
detach_hooks: true,
keep_scratch: options.keep_scratch,
@@ -672,14 +638,11 @@ impl HomeView {
options.delete_worktrees && inst.has_managed_worktree_or_workspace();
let delete_branch =
options.delete_branches && inst.has_managed_worktree_or_workspace();
let delete_sandbox = options.delete_containers
&& inst.sandbox_info.as_ref().is_some_and(|s| s.enabled);
let request = DeletionRequest {
session_id: session_id.clone(),
instance: inst.clone(),
delete_worktree,
delete_branch,
delete_sandbox,
force_delete: options.force_delete_worktrees,
detach_hooks: true,
// Group-delete UX doesn't have a per-session
@@ -729,17 +692,6 @@ impl HomeView {
panic
);
}
if inst.sandbox_info.as_ref().is_some_and(|s| s.enabled) {
let container = crate::containers::DockerContainer::from_session_id(&inst.id);
if let crate::containers::Teardown::Failed(e) = container.teardown(&inst.id) {
tracing::warn!(
target: "session.delete",
session_id = %inst.id,
"force_remove container teardown failed: {}",
e
);
}
}
});
}
Ok(())
@@ -756,17 +708,6 @@ impl HomeView {
.any(|i| is_member(i) && i.has_managed_worktree_or_workspace())
}
pub(super) fn group_has_containers(
&self,
group_path: &str,
prefix: &str,
owning_profile: Option<&str>,
) -> bool {
let is_member = group_membership(group_path, prefix, owning_profile);
self.instances()
.any(|i| is_member(i) && i.sandbox_info.as_ref().is_some_and(|s| s.enabled))
}
/// Rename a group in-place: the old group path is removed and all sessions and
/// sub-groups follow the new name. Re-sorting happens automatically on reload.
pub(super) fn rename_selected_group(
@@ -914,15 +855,10 @@ impl HomeView {
let Some(id) = self.selected_session.clone() else {
return Ok(());
};
let snapshot = self.get_instance(&id).map(|i| {
(
i.worktree_info.clone(),
i.status,
i.project_path.clone(),
i.is_sandboxed(),
)
});
let Some((worktree_info, status, project_path, is_sandboxed)) = snapshot else {
let snapshot = self
.get_instance(&id)
.map(|i| (i.worktree_info.clone(), i.status, i.project_path.clone()));
let Some((worktree_info, status, project_path)) = snapshot else {
anyhow::bail!("Session not found");
};
let Some(worktree_info) = worktree_info else {
@@ -932,26 +868,6 @@ impl HomeView {
anyhow::bail!("Stop the session before editing its workdir name");
}
// A sandbox session keeps its container alive (running `sleep infinity`)
// even while Idle, and that container bind-mounts the worktree dir, so
// the `git worktree move` below would hit EBUSY, and a reused container
// would keep mounting (and `cd`-ing into) the old path. Refuse until the
// session is stopped, mirroring the tied-rename path. `status` alone is
// insufficient: `blocks_worktree_edit` is false for an Idle session
// whose container is still up. See #2117, #2414.
// Gated on the directory actually moving: the helper discards a
// stopped container, which is only worth doing for a real relocation.
// A no-op or branch-only edit leaves the mount valid.
if crate::session::worktree_edit::worktree_move_required(
std::path::Path::new(&project_path),
new_name,
) && crate::session::worktree_edit::ensure_sandbox_container_released(&id, is_sandboxed)
{
anyhow::bail!(
"Stop the session before editing its workdir name: its sandbox container is \
mounting the worktree directory"
);
}
let outcome = crate::session::worktree_edit::edit_worktree_workdir(
crate::session::worktree_edit::WorktreeEditRequest {
worktree_info: &worktree_info,
@@ -963,17 +879,6 @@ impl HomeView {
let new_path = outcome.new_path.to_string_lossy().to_string();
let new_branch = outcome.new_branch.clone();
// A container created against the old path is now stale: its mounts and
// working dir are baked in at create time and do NOT follow a host-side
// `git worktree move`, so a reused container would `docker exec -w` into
// a path that no longer exists. Drop it to force a fresh create on next
// start. Only when the dir actually moved; a branch-only rename leaves
// the path valid. Mirrors `rename_selected` (#2117).
let dir_moved = outcome.new_path != std::path::Path::new(&project_path);
if dir_moved {
crate::session::worktree_edit::discard_sandbox_container_after_move(&id, is_sandboxed);
}
self.apply_user_action(&id, |inst| {
inst.project_path = new_path.clone();
if let Some(branch) = &new_branch {
@@ -1089,51 +994,16 @@ impl HomeView {
// rename the branch (which may be requested even with the title
// unchanged, to bring a drifted branch back in line with the dir).
if tied_edit {
let snapshot = self.get_instance(&id).map(|i| {
(
i.worktree_info.clone(),
i.status,
i.project_path.clone(),
i.is_sandboxed(),
)
});
if let Some((Some(worktree_info), status, project_path, is_sandboxed)) = snapshot {
// A sandbox session keeps its container alive (running
// `sleep infinity`) even while the agent is Idle, and that
// container bind-mounts the worktree directory. The move
// below `git worktree move`s that dir, which fails while it
// is an active mount source ("fatal: failed to move"). The
// gate releases a merely-stopped container itself and only
// reports held when the agent is genuinely live, in which
// case the user has to stop the session. We only inspect
// the container when the status check hasn't already
// blocked, so the common non-sandbox path spawns no
// `docker inspect`. See #1927 follow-up and #3171.
// Gated on the directory actually moving, matching the
// `dir_moved` guard on the post-move discard below: a
// branch-only rename leaves the path, and thus the mount,
// valid, so there is no container to release.
let snapshot = self
.get_instance(&id)
.map(|i| (i.worktree_info.clone(), i.status, i.project_path.clone()));
if let Some((Some(worktree_info), status, project_path)) = snapshot {
let leaf =
crate::session::worktree_edit::worktree_leaf_from_title(&effective_title);
let container_holds_worktree = !status.blocks_worktree_edit()
&& crate::session::worktree_edit::worktree_move_required(
std::path::Path::new(&project_path),
&leaf,
)
&& crate::session::worktree_edit::ensure_sandbox_container_released(
&id,
is_sandboxed,
);
if let Some(reason) =
worktree_rename_block(status, is_sandboxed, container_holds_worktree)
{
let body = match reason {
WorktreeRenameBlock::ActiveAgent => "This worktree session's directory moves to match the new name, which can't happen while it's running. Stop the session first, or disable \"Tie Worktree Directory to Session Name\" to relabel it freely.",
WorktreeRenameBlock::SandboxContainer => "This sandbox session's container is mounting the worktree directory, so it can't be moved to match the new name. Stop the session first, or disable \"Tie Worktree Directory to Session Name\" to relabel it freely.",
};
if status.blocks_worktree_edit() {
self.info_dialog = Some(crate::tui::dialogs::InfoDialog::new(
"Stop the Session to Rename",
body,
"This worktree session's directory moves to match the new name, which can't happen while it's running. Stop the session first, or disable \"Tie Worktree Directory to Session Name\" to relabel it freely.",
));
return Ok(());
}
@@ -1146,20 +1016,8 @@ impl HomeView {
},
) {
Ok(outcome) => {
// Discard the stale container only when the dir
// actually moved. A branch-only rename (title
// unchanged, toggle armed) leaves the path, and thus
// the mount and working dir, valid, so there is
// nothing stale to recreate.
let dir_moved = outcome.new_path != std::path::Path::new(&project_path);
new_path = Some(outcome.new_path.to_string_lossy().to_string());
new_branch = outcome.new_branch;
if dir_moved {
crate::session::worktree_edit::discard_sandbox_container_after_move(
&id,
is_sandboxed,
);
}
}
// Leaf maps to the current dir and no branch rename was
// requested: nothing to move, just rename the title.
@@ -1722,15 +1580,11 @@ impl HomeView {
let delete_worktree =
config.worktree.auto_cleanup && inst.has_managed_worktree_or_workspace();
let delete_branch = delete_worktree && config.worktree.delete_branch_on_cleanup;
let delete_sandbox = inst.sandbox_info.as_ref().is_some_and(|s| s.enabled)
&& config.sandbox.auto_cleanup;
self.deletion_poller.request_deletion(DeletionRequest {
session_id: id.clone(),
instance: inst.clone(),
delete_worktree,
delete_branch,
delete_sandbox,
force_delete: true,
detach_hooks: true,
keep_scratch: false,
@@ -1959,60 +1813,3 @@ fn restore_from_trash_with_storage(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// An Idle sandbox session whose container is still running is the #1927
// follow-up bug: the worktree dir is an active bind-mount source, so
// `git worktree move` fails with EBUSY. Before the fix this returned
// `None` (the rename proceeded and the move blew up with "fatal: failed
// to move"); it must now block with the sandbox-specific reason.
#[test]
fn idle_sandbox_with_running_container_blocks() {
assert_eq!(
worktree_rename_block(Status::Idle, true, true),
Some(WorktreeRenameBlock::SandboxContainer)
);
}
#[test]
fn idle_sandbox_with_stopped_container_is_safe() {
// Stopping the session tears the container down, releasing the mount.
assert_eq!(worktree_rename_block(Status::Idle, true, false), None);
}
#[test]
fn idle_non_sandbox_is_safe() {
// No container, nothing holds the dir; the move proceeds.
assert_eq!(worktree_rename_block(Status::Idle, false, false), None);
}
#[test]
fn active_status_blocks_as_active_agent() {
for status in [
Status::Running,
Status::Waiting,
Status::Starting,
Status::Creating,
Status::Deleting,
] {
assert_eq!(
worktree_rename_block(status, false, false),
Some(WorktreeRenameBlock::ActiveAgent),
"{status:?} should block as ActiveAgent"
);
}
}
#[test]
fn active_status_takes_precedence_over_container() {
// A busy agent reports as ActiveAgent even on a sandbox session with a
// live container; status is checked first.
assert_eq!(
worktree_rename_block(Status::Running, true, true),
Some(WorktreeRenameBlock::ActiveAgent)
);
}
}
+39 -332
View File
@@ -9,11 +9,10 @@ use std::time::{Duration, Instant};
use rattles::presets::prelude as spinners;
use super::{
get_indent, live_send, HomeView, TerminalMode, ViewMode, ICON_ARCHIVED_SECTION, ICON_COLLAPSED,
get_indent, live_send, HomeView, ViewMode, ICON_ARCHIVED_SECTION, ICON_COLLAPSED,
ICON_DELETING, ICON_DORMANT, ICON_ERROR, ICON_EXPANDED, ICON_IDLE, ICON_PINNED, ICON_STOPPED,
ICON_TRASH_SECTION, ICON_UNKNOWN, ICON_UNREAD,
};
use crate::containers::image_update::ImageUpdate;
use crate::session::config::{GroupByMode, RowTagMode, SortOrder};
use crate::session::{Item, Status};
use crate::tui::components::preview::{self, CachedPreview};
@@ -488,16 +487,6 @@ pub(crate) fn compute_row_tag(
})
}
}
RowTagMode::Sandbox => {
if inst.is_sandboxed() {
Some(RowTag {
content: "sb".to_string(),
max_width: 2,
})
} else {
None
}
}
RowTagMode::Branch => branch_row_tag(inst),
}
}
@@ -658,7 +647,6 @@ impl HomeView {
area: Rect,
theme: &Theme,
update_status: Option<&str>,
image_update: Option<&ImageUpdate>,
) {
// Start each frame with no footer buttons and no sidebar
// collapse/expand hit rects; the home-view render paths
@@ -696,10 +684,9 @@ impl HomeView {
}
// Layout: main area + status bar + optional update bar at bottom.
// The bar carries transient toasts (update_status) and the
// sandbox-image banner; we need a row for it whenever either is
// present.
let has_update_bar = update_status.is_some() || image_update.is_some();
// The bar carries transient toasts (update_status); we need a row for
// it whenever one is present.
let has_update_bar = update_status.is_some();
let constraints = if has_update_bar {
vec![
Constraint::Min(0),
@@ -796,7 +783,7 @@ impl HomeView {
self.render_status_bar(frame, main_chunks[1], theme);
if has_update_bar {
self.render_update_bar(frame, main_chunks[2], theme, update_status, image_update);
self.render_update_bar(frame, main_chunks[2], theme, update_status);
}
// Render dialogs on top
@@ -855,7 +842,6 @@ impl HomeView {
project_session_picker_dialog,
projects_dialog,
command_palette,
tool_picker_dialog,
send_message_dialog,
permission_response_dialog,
// context_menu renders last so its small popup sits on top of
@@ -902,7 +888,7 @@ impl HomeView {
self.expand_strip_area = area;
let border_color = match self.view_mode {
ViewMode::Structured => theme.border,
ViewMode::Terminal | ViewMode::Tool(_) => theme.terminal_border,
ViewMode::Terminal => theme.terminal_border,
};
// Drop the right border so the preview's left border is the single
// shared seam, matching the expanded list and DESIGN.md's
@@ -944,18 +930,10 @@ impl HomeView {
ViewMode::Terminal => {
compose_list_title("Terminals", profile, self.group_by, self.sort_order)
}
ViewMode::Tool(name) => compose_list_title(
&format!("Tool: {}", name),
profile,
self.group_by,
self.sort_order,
),
};
let (border_color, title_color) = match self.view_mode {
ViewMode::Structured => (theme.border, theme.title),
ViewMode::Terminal | ViewMode::Tool(_) => {
(theme.terminal_border, theme.terminal_border)
}
ViewMode::Terminal => (theme.terminal_border, theme.terminal_border),
};
let borders = layout.list_borders();
// The Trash / Archived sections render in a pinned bottom shelf rather
@@ -1517,22 +1495,10 @@ impl HomeView {
(icon, title_text, style)
}
ViewMode::Terminal => {
// For sandboxed sessions, check the appropriate terminal based on mode
let terminal_mode = if inst.is_sandboxed() {
self.get_terminal_mode(id)
} else {
TerminalMode::Host
};
let terminal_running = match terminal_mode {
TerminalMode::Container => inst
.container_terminal_tmux_session()
.map(|s| s.exists())
.unwrap_or(false),
TerminalMode::Host => inst
.terminal_tmux_session()
.map(|s| s.exists())
.unwrap_or(false),
};
let terminal_running = inst
.terminal_tmux_session()
.map(|s| s.exists())
.unwrap_or(false);
// Unread is an Agent-view concept: it means the agent
// produced output the user hasn't looked at. The
// paired terminal has no such notion, so Terminal
@@ -1572,28 +1538,6 @@ impl HomeView {
};
(icon, title_text, style)
}
ViewMode::Tool(ref tool_name) => {
let tool_session =
crate::tmux::ToolSession::new(&inst.id, &inst.title, tool_name);
let tool_running =
tool_session.exists() && !tool_session.is_pane_dead();
let (icon, color) = if tool_running {
(spinner_running(&inst.created_at), theme.terminal_active)
} else {
(ICON_IDLE, theme.dimmed)
};
let mut style = Style::default().fg(color);
let title_text =
if show_favorite && crate::session::is_live_favorite(inst) {
style = style
.add_modifier(ratatui::style::Modifier::BOLD)
.add_modifier(ratatui::style::Modifier::UNDERLINED);
Cow::Owned(format!("* {}", inst.title))
} else {
Cow::Owned(inst.title.clone())
};
(icon, title_text, style)
}
}
} else {
(
@@ -1684,16 +1628,10 @@ impl HomeView {
// structured-view sessions natively; `[structured]`
// better describes the view the badge marks.
Some(" [structured]")
} else if self.view_mode == ViewMode::Terminal && inst.is_sandboxed() {
Some(match self.get_terminal_mode(id) {
TerminalMode::Container => " [container]",
TerminalMode::Host => " [host]",
})
} else if inst.is_structured() {
// Terminal view, non-sandboxed: the container/host
// badge doesn't apply, so keep marking structured
// rows; without it Enter opening the structured
// view (not a tmux attach) comes as a surprise.
// Terminal view: keep marking structured rows; without
// it Enter opening the structured view (not a tmux
// attach) comes as a surprise.
Some(" [structured]")
} else {
None
@@ -1853,24 +1791,7 @@ impl HomeView {
let inst = self.get_instance(id)?;
let name = match &self.view_mode {
ViewMode::Structured => crate::tmux::Session::resolve_name(&inst.id, &inst.title),
ViewMode::Terminal => {
let mode = if inst.is_sandboxed() {
self.get_terminal_mode(id)
} else {
TerminalMode::Host
};
match mode {
TerminalMode::Host => {
crate::tmux::TerminalSession::resolve_name(&inst.id, &inst.title)
}
TerminalMode::Container => {
crate::tmux::ContainerTerminalSession::resolve_name(&inst.id, &inst.title)
}
}
}
ViewMode::Tool(tool) => crate::tmux::ToolSession::new(&inst.id, &inst.title, tool)
.session_name()
.to_string(),
ViewMode::Terminal => crate::tmux::TerminalSession::resolve_name(&inst.id, &inst.title),
};
Some(name)
}
@@ -2195,74 +2116,11 @@ impl HomeView {
);
}
/// Refresh container terminal preview cache if needed
fn refresh_container_terminal_preview_cache_if_needed(&mut self, width: u16, height: u16) {
// Symmetric with `refresh_preview_cache_if_needed`: when live-send
// is pointed at the in-container shell, keep its tmux pane sized to
// the visible output area so a window resize or info-header toggle
// reflows immediately.
self.resize_live_pane_if_target(
live_send::LiveSendTarget::ContainerTerminal,
width,
height,
);
if self.apply_worker_capture(width, height, |s| &mut s.container_terminal_preview_cache) {
return;
}
self.refresh_preview_cache_core(
width,
height,
false,
|s| &mut s.container_terminal_preview_cache,
|s, id, capture_lines| {
s.get_instance(id).map(|inst| {
inst.container_terminal_tmux_session()
.and_then(|sess| sess.capture_window_composited(capture_lines))
.unwrap_or_default()
})
},
);
}
pub(super) fn refresh_tool_preview_cache_if_needed(
&mut self,
width: u16,
height: u16,
tool_name: &str,
) {
// Symmetric with `refresh_terminal_preview_cache_if_needed` /
// `refresh_container_terminal_preview_cache_if_needed`: when live-send
// is pointed at this tool pane (lazygit, yazi, etc.), keep its tmux
// pane sized to the visible output area so a window resize or
// info-header toggle reflows immediately.
self.resize_live_pane_if_target(
live_send::LiveSendTarget::Tool(tool_name.to_string()),
width,
height,
);
if self.apply_worker_capture(width, height, |s| &mut s.tool_preview_cache) {
return;
}
self.refresh_preview_cache_core(
width,
height,
false,
|s| &mut s.tool_preview_cache,
|s, id, capture_lines| {
s.get_instance(id).map(|inst| {
crate::tmux::ToolSession::new(&inst.id, &inst.title, tool_name)
.capture_window_composited(capture_lines)
.unwrap_or_default()
})
},
);
}
/// `captured_lines` from whichever preview cache is currently on screen.
/// Both the preview's own scroll indicator and the live-send footer need
/// the active view's line count; reading `preview_cache` (the Agent cache)
/// unconditionally shows a stale or empty `[offset/max]` in Terminal or
/// Tool live mode, where a different cache backs the visible output.
/// unconditionally shows a stale or empty `[offset/max]` in Terminal
/// live mode, where a different cache backs the visible output.
/// Record the output pane's text layout for the drag-select handlers.
/// `total_lines` is the parsed scrollback length; `first_line` is
/// derived from the same `compute_scroll` the renderer feeds to
@@ -2281,33 +2139,14 @@ impl HomeView {
};
}
/// The preview cache backing whatever the pane currently shows,
/// resolving the sandbox container-vs-host split for Terminal view.
/// The preview cache backing whatever the pane currently shows.
/// Shared by the scroll clamp, the scroll indicator, and the
/// drag-select copy so they all read the same content the renderer
/// painted.
pub(super) fn active_preview_cache(&self) -> &super::PreviewCache {
match &self.view_mode {
ViewMode::Structured => &self.preview_cache,
ViewMode::Tool(_) => &self.tool_preview_cache,
ViewMode::Terminal => {
let mode = self
.selected_session
.as_ref()
.and_then(|id| self.get_instance(id).map(|inst| (id, inst)))
.map(|(id, inst)| {
if inst.is_sandboxed() {
self.get_terminal_mode(id)
} else {
TerminalMode::Host
}
})
.unwrap_or(TerminalMode::Host);
match mode {
TerminalMode::Container => &self.container_terminal_preview_cache,
TerminalMode::Host => &self.terminal_preview_cache,
}
}
ViewMode::Terminal => &self.terminal_preview_cache,
}
}
@@ -2334,9 +2173,7 @@ impl HomeView {
let compact = area.width < responsive::STACKED_BREAKPOINT;
let (border_color, title_color) = match self.view_mode {
ViewMode::Structured => (theme.border, theme.title),
ViewMode::Terminal | ViewMode::Tool(_) => {
(theme.terminal_border, theme.terminal_border)
}
ViewMode::Terminal => (theme.terminal_border, theme.terminal_border),
};
// Live-send mode swaps the preview border and title to `accent`
// so the pane visually matches the M-compose modal's border
@@ -2422,7 +2259,6 @@ impl HomeView {
let title = match &self.view_mode {
ViewMode::Structured => " Preview ".to_string(),
ViewMode::Terminal => " Terminal Preview ".to_string(),
ViewMode::Tool(name) => format!(" {} Preview ", name),
};
block = block
.title(title)
@@ -2739,17 +2575,6 @@ impl HomeView {
let selected_id = self.selected_session.clone();
if let Some(id) = selected_id {
// Determine which terminal to preview based on mode
let terminal_mode = if let Some(inst) = self.get_instance(&id) {
if inst.is_sandboxed() {
self.get_terminal_mode(&id)
} else {
TerminalMode::Host
}
} else {
TerminalMode::Host
};
// Compute the output sub-rect symmetric with Agent
// view: when the info header is visible we strip the
// header rows + one banner row off `inner`, so the
@@ -2777,56 +2602,28 @@ impl HomeView {
self.preview_pane_area = pane_area;
self.preview_visible_rows = pane_area.height as usize;
// Refresh the appropriate cache, then warm the
// matching `parsed_text` so the render call below
// can read it via a shared borrow alongside
// Refresh the cache, then warm `parsed_text` so the render
// call below can read it via a shared borrow alongside
// `get_instance`.
match terminal_mode {
TerminalMode::Container => {
self.refresh_container_terminal_preview_cache_if_needed(
pane_area.width,
pane_area.height,
);
self.container_terminal_preview_cache.ensure_parsed();
}
TerminalMode::Host => {
self.refresh_terminal_preview_cache_if_needed(
pane_area.width,
pane_area.height,
);
self.terminal_preview_cache.ensure_parsed();
}
}
let total_lines = match terminal_mode {
TerminalMode::Container => &self.container_terminal_preview_cache,
TerminalMode::Host => &self.terminal_preview_cache,
}
.parsed_text
.as_ref()
.map_or(0, |t| t.lines.len());
self.refresh_terminal_preview_cache_if_needed(
pane_area.width,
pane_area.height,
);
self.terminal_preview_cache.ensure_parsed();
let total_lines = self
.terminal_preview_cache
.parsed_text
.as_ref()
.map_or(0, |t| t.lines.len());
self.set_preview_text_view(pane_area, total_lines);
// Now borrow instance for rendering
if let Some(inst) = self.get_instance(&id) {
let (terminal_running, preview_text) = match terminal_mode {
TerminalMode::Container => {
let running = inst
.container_terminal_tmux_session()
.map(|s| s.exists())
.unwrap_or(false);
(
running,
self.container_terminal_preview_cache.parsed_text.as_ref(),
)
}
TerminalMode::Host => {
let running = inst
.terminal_tmux_session()
.map(|s| s.exists())
.unwrap_or(false);
(running, self.terminal_preview_cache.parsed_text.as_ref())
}
};
let terminal_running = inst
.terminal_tmux_session()
.map(|s| s.exists())
.unwrap_or(false);
let preview_text = self.terminal_preview_cache.parsed_text.as_ref();
Preview::render_terminal_preview(
frame,
@@ -2847,66 +2644,6 @@ impl HomeView {
frame.render_widget(hint, inner);
}
}
ViewMode::Tool(ref tool_name) => {
let tool_name = tool_name.clone();
let selected_id = self.selected_session.clone();
if let Some(id) = selected_id {
// Same single-source split as the Agent branch: the tmux
// pane is sized to `PreviewLayout::compute(..).output`, which
// `render_terminal_preview` also paints into.
let pane_area = self
.get_instance(&id)
.map(|inst| {
preview::PreviewLayout::compute(
inner,
compact,
self.show_preview_info,
preview::terminal_info_height(inst),
)
.output
})
.unwrap_or(inner);
self.preview_pane_area = pane_area;
self.preview_visible_rows = pane_area.height as usize;
self.refresh_tool_preview_cache_if_needed(
pane_area.width,
pane_area.height,
&tool_name,
);
self.tool_preview_cache.ensure_parsed();
let total_lines = self
.tool_preview_cache
.parsed_text
.as_ref()
.map_or(0, |t| t.lines.len());
self.set_preview_text_view(pane_area, total_lines);
if let Some(inst) = self.get_instance(&id) {
let tool_session =
crate::tmux::ToolSession::new(&inst.id, &inst.title, &tool_name);
let tool_running = tool_session.exists() && !tool_session.is_pane_dead();
Preview::render_terminal_preview(
frame,
inner,
inst,
tool_running,
CachedPreview::from_text(self.tool_preview_cache.parsed_text.as_ref()),
self.preview_scroll_offset,
theme,
compact,
self.show_preview_info,
);
}
} else {
let hint = Paragraph::new("Select a session to preview tool")
.style(Style::default().fg(theme.dimmed))
.alignment(Alignment::Center);
frame.render_widget(hint, inner);
}
}
}
// In live-send mode, place a real terminal cursor over the preview at
@@ -3645,32 +3382,12 @@ impl HomeView {
kc(if strict { 'T' } else { 't' }),
mk(if strict { "T" } else { "t" }, "View"),
));
if matches!(self.view_mode, ViewMode::Tool(_)) {
groups.push((1, kc(';'), mk(";", "Back")));
} else if !self.tool_configs.is_empty() {
groups.push((2, kc(';'), mk(";", "Tools")));
}
groups.push((
3,
if strict { kctrl('g') } else { kc('g') },
mk(if strict { "^G" } else { "g" }, "Group"),
));
// c: container/host toggle hint for sandboxed sessions in Terminal view
if self.view_mode == ViewMode::Terminal {
if let Some(id) = &self.selected_session {
if let Some(inst) = self.get_instance(id) {
if inst.is_sandboxed() {
groups.push((
4,
kc(if strict { 'C' } else { 'c' }),
mk(if strict { "C" } else { "c" }, "Mode"),
));
}
}
}
}
// New session. In non-strict mode bare `n` is the usual chord, but while
// a committed search borrows `n` for cycling, advertise Shift+N (which
// still creates, #3038) so the footer never claims `n` makes a session
@@ -3824,22 +3541,12 @@ impl HomeView {
area: Rect,
theme: &Theme,
status: Option<&str>,
image_update: Option<&ImageUpdate>,
) {
let update_style = Style::default().fg(theme.waiting).bold();
// Precedence (highest first): transient status, then the sandbox-image
// update. Only one banner shows at a time, so its keys ([u]/[Ctrl+x])
// are unambiguous; the image banner surfaces once a toast clears.
// `u` is matched directly in `App::handle_key` (guarded on
// `image_banner_active`), not through the binding registry, so it is
// the same key in strict mode.
let text = if let Some(s) = status {
format!(" {s} [Ctrl+x] dismiss")
} else if image_update.is_some() {
" sandbox image update available [u] pull [Ctrl+x] dismiss".to_string()
} else {
let Some(s) = status else {
return;
};
let text = format!(" {s} [Ctrl+x] dismiss");
let bar = Paragraph::new(Line::from(Span::styled(text, update_style)))
.style(Style::default().bg(theme.selection));
frame.render_widget(bar, area);
+8 -1597
View File
File diff suppressed because it is too large Load Diff
-27
View File
@@ -1290,33 +1290,6 @@ mod tests {
);
}
#[test]
fn profile_field_inherits_after_global_change() {
let mut global = Config::default();
let profile = ProfileConfig::default();
let fields = build_fields_for_category(
SettingsCategory::Updates,
SettingsScope::Profile,
&global,
&profile,
);
assert!(!field(&fields, "updates.update_check_mode").has_override);
// Changing the global value must not promote the profile to "override".
global.updates.update_check_mode = crate::session::config::UpdateCheckMode::Off;
let fields = build_fields_for_category(
SettingsCategory::Updates,
SettingsScope::Profile,
&global,
&profile,
);
assert!(
!field(&fields, "updates.update_check_mode").has_override,
"profile should inherit, not show an override, after a global change"
);
}
#[test]
fn profile_field_shows_override_after_profile_change() {
let global = Config::default();
-139
View File
@@ -14,7 +14,6 @@
//! (Stopped/Deleting) never.
use std::collections::HashMap;
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
@@ -104,25 +103,12 @@ pub(crate) struct StatusUpdate {
}
pub(super) struct StatusPollState {
container_check_interval: Duration,
last_container_check: Instant,
container_states: HashMap<String, bool>,
credential_refresh_interval: Duration,
last_credential_refresh: Instant,
cycle_count: u64,
}
impl StatusPollState {
pub(super) fn new() -> Self {
let container_check_interval = Duration::from_secs(5);
let credential_refresh_interval = Duration::from_secs(1800);
Self {
container_check_interval,
last_container_check: Instant::now() - container_check_interval,
container_states: HashMap::new(),
credential_refresh_interval,
last_credential_refresh: Instant::now(),
cycle_count: TIER_COLD - 1,
}
}
@@ -158,34 +144,6 @@ pub(super) fn poll_statuses_once(
HashMap::new()
};
// Refresh container health if any sandboxed session exists and interval elapsed
let has_sandboxed = if any_pollable {
let sandboxed = instances.iter().any(|i| i.is_sandboxed());
if sandboxed && state.last_container_check.elapsed() >= state.container_check_interval {
state.container_states = crate::containers::batch_container_health();
state.last_container_check = Instant::now();
}
sandboxed
} else {
false
};
// Periodically re-sync sandbox credentials from the macOS Keychain
// so long-lived sessions don't lose auth mid-run.
if has_sandboxed && state.last_credential_refresh.elapsed() >= state.credential_refresh_interval
{
state.last_credential_refresh = Instant::now();
crate::session::container_config::refresh_shared_agent_configs();
for instance in instances.iter().filter(|inst| inst.is_sandboxed()) {
crate::session::container_config::refresh_codex_agent_config_for_instance(
&instance.effective_profile(),
&instance.id,
&instance.tool,
Some(&instance.detect_as),
);
}
}
instances
.into_iter()
.filter_map(|mut inst| {
@@ -224,33 +182,6 @@ pub(super) fn poll_statuses_once(
return None;
}
// For sandboxed sessions, check if the container is dead before
// falling through to tmux-based status detection.
if inst.is_sandboxed()
&& !matches!(
inst.status,
Status::Stopped | Status::Deleting | Status::Starting | Status::Creating
)
{
if let Some(sandbox) = &inst.sandbox_info {
if let Some(&running) = state.container_states.get(&sandbox.container_name) {
if !running {
return Some(StatusUpdate {
id: inst.id,
status: Status::Error,
last_error: Some("Container is not running".to_string()),
idle_entered_at: IdleIntent::Clear,
last_accessed_at: inst.last_accessed_at,
// Sandboxed sessions don't have a tmux pane in the
// usual sense; the Error tier itself sinks the row.
pane_dead: false,
live_status_baseline: Some(Status::Error),
});
}
}
}
}
// Look up pre-fetched metadata for this instance's tmux session,
// resolving the name against this same snapshot so a session whose
// title moved without its tmux session being renamed is still
@@ -481,74 +412,4 @@ mod tests {
);
}
}
fn dead_container_sandbox(name: &str) -> crate::session::SandboxInfo {
crate::session::SandboxInfo {
enabled: true,
container_id: None,
image: "ubuntu:latest".to_string(),
container_name: name.to_string(),
extra_env: None,
custom_instruction: None,
before_start_env: Vec::new(),
container_workdir: None,
}
}
/// Pin `container_states` for the per-instance decision: the live
/// `batch_container_health()` refresh would otherwise overwrite the seeded
/// map with an empty one (no docker in the test environment), and an
/// absent entry skips the sandbox-dead branch for the wrong reason.
fn state_with_dead_container(name: &str) -> StatusPollState {
let mut state = StatusPollState::new();
state.last_container_check = Instant::now();
state.container_states.insert(name.to_string(), false);
state
}
#[test]
#[serial_test::serial]
fn structured_rows_never_get_a_container_error() {
// The sandbox-dead branch used to run before the `is_structured()`
// bail, making it the one tmux/docker-derived write that landed on a
// structured row: a red `Error` with "Container is not running" that
// `home::apply_status_update` then persisted, and that the heal in
// `update_status_with_metadata_inner` only half-cleared (status back
// to Idle, message left behind). A structured session's container
// belongs to its ACP worker, not to a tmux pane, so this reading says
// nothing about the session; the daemon overlay owns its status.
let mut inst = Instance::new("structured-sandboxed", "/tmp/structured");
inst.view = crate::session::View::Structured;
inst.status = Status::Idle;
inst.sandbox_info = Some(dead_container_sandbox("aoe-sandbox-structured"));
let mut state = state_with_dead_container("aoe-sandbox-structured");
let updates = poll_statuses_once(vec![inst], &mut state);
assert!(
updates.is_empty(),
"structured rows must produce no tmux/docker status update; got {updates:?}"
);
}
#[test]
#[serial_test::serial]
fn terminal_sandboxed_rows_still_get_a_container_error() {
// The other half of the guard above: bailing on structured rows must
// not disarm the sandbox-dead branch for terminal ones, where a dead
// container really does mean the session is broken.
let mut inst = Instance::new("terminal-sandboxed", "/tmp/terminal");
inst.status = Status::Idle;
inst.sandbox_info = Some(dead_container_sandbox("aoe-sandbox-terminal"));
let mut state = state_with_dead_container("aoe-sandbox-terminal");
let updates = poll_statuses_once(vec![inst], &mut state);
assert_eq!(updates.len(), 1);
assert_eq!(updates[0].status, Status::Error);
assert_eq!(
updates[0].last_error.as_deref(),
Some("Container is not running")
);
}
}
+187 -213
View File
@@ -183,219 +183,6 @@ fn test_archive_then_unarchive_cycle() {
);
}
/// Locks #1868: archive kills all four tmux session kinds. Pre-creates real
/// sessions, runs the CLI, asserts each kind is gone.
#[test]
#[parallel]
fn test_cli_archive_kills_agent_and_terminal_tmux_sessions() {
require_tmux!();
let h = TuiTestHarness::new("cli_archive_full_teardown");
let project = h.project_path();
let add_output = h.run_cli(&["add", project.to_str().unwrap(), "-t", "ArchiveTeardown"]);
assert!(
add_output.status.success(),
"aoe add failed: {}",
String::from_utf8_lossy(&add_output.stderr)
);
let sessions = read_sessions_json(&h);
let session_id = sessions[0]["id"]
.as_str()
.expect("session should have id")
.to_string();
let truncated_id = session_id[..8.min(session_id.len())].to_string();
let agent_tmux_name = format!(
"{}ArchiveTeardown_{}",
agent_of_empires::tmux::SESSION_PREFIX,
truncated_id
);
let terminal_tmux_name =
agent_of_empires::tmux::TerminalSession::generate_name(&session_id, "ArchiveTeardown");
let cterm_tmux_name = agent_of_empires::tmux::ContainerTerminalSession::generate_name(
&session_id,
"ArchiveTeardown",
);
let tool_tmux_name =
agent_of_empires::tmux::ToolSession::new(&session_id, "ArchiveTeardown", "lazygit")
.session_name()
.to_string();
// Pre-create the four tmux kinds so archive can find and kill them. Created
// on the harness's tmux socket (`AOE_TMUX_SOCKET`), the same one the CLI
// resolves (#2608), so archive's teardown sweeps them.
let sock = h.home_path().join("tmux.sock");
let names = [
&agent_tmux_name,
&terminal_tmux_name,
&cterm_tmux_name,
&tool_tmux_name,
];
for name in names {
let create = Command::new("tmux")
.arg("-S")
.arg(&sock)
.args([
"new-session",
"-d",
"-s",
name,
"-x",
"80",
"-y",
"24",
"sleep",
"600",
])
.output()
.expect("tmux new-session");
assert!(
create.status.success(),
"failed to create tmux session {}: {}",
name,
String::from_utf8_lossy(&create.stderr)
);
}
let archive_output = h.run_cli(&["session", "archive", &session_id]);
assert!(
archive_output.status.success(),
"aoe session archive failed: {}",
String::from_utf8_lossy(&archive_output.stderr)
);
let alive: Vec<(&&String, bool)> = names
.iter()
.map(|n| (n, tmux_has_session(&sock, n)))
.collect();
// Cleanup BEFORE asserting so a single failure cannot leak survivors
// into the next serial test.
for (name, is_alive) in &alive {
if *is_alive {
kill_tmux(&sock, name);
}
}
for (name, is_alive) in &alive {
assert!(
!is_alive,
"tmux session '{}' must be killed by archive (#1868)",
name
);
}
}
/// Locks the widened `--no-kill` semantic from #1868: skip ALL tmux
/// teardown. The agent assertion is the pre/post differentiator.
#[test]
#[parallel]
fn test_cli_archive_no_kill_preserves_all_tmux_sessions() {
require_tmux!();
let h = TuiTestHarness::new("cli_archive_no_kill");
let project = h.project_path();
let add_output = h.run_cli(&["add", project.to_str().unwrap(), "-t", "ArchiveNoKill"]);
assert!(
add_output.status.success(),
"aoe add failed: {}",
String::from_utf8_lossy(&add_output.stderr)
);
let sessions = read_sessions_json(&h);
let session_id = sessions[0]["id"]
.as_str()
.expect("session should have id")
.to_string();
let truncated_id = session_id[..8.min(session_id.len())].to_string();
let agent_tmux_name = format!(
"{}ArchiveNoKill_{}",
agent_of_empires::tmux::SESSION_PREFIX,
truncated_id
);
let terminal_tmux_name =
agent_of_empires::tmux::TerminalSession::generate_name(&session_id, "ArchiveNoKill");
let cterm_tmux_name = agent_of_empires::tmux::ContainerTerminalSession::generate_name(
&session_id,
"ArchiveNoKill",
);
let tool_tmux_name =
agent_of_empires::tmux::ToolSession::new(&session_id, "ArchiveNoKill", "lazygit")
.session_name()
.to_string();
let sock = h.home_path().join("tmux.sock");
let names = [
&agent_tmux_name,
&terminal_tmux_name,
&cterm_tmux_name,
&tool_tmux_name,
];
for name in names {
let create = Command::new("tmux")
.arg("-S")
.arg(&sock)
.args([
"new-session",
"-d",
"-s",
name,
"-x",
"80",
"-y",
"24",
"sleep",
"600",
])
.output()
.expect("tmux new-session");
assert!(
create.status.success(),
"failed to create tmux session {}: {}",
name,
String::from_utf8_lossy(&create.stderr)
);
}
let archive_output = h.run_cli(&["session", "archive", &session_id, "--no-kill"]);
assert!(
archive_output.status.success(),
"aoe session archive --no-kill failed: {}",
String::from_utf8_lossy(&archive_output.stderr)
);
let alive: Vec<(&&String, bool)> = names
.iter()
.map(|n| (n, tmux_has_session(&sock, n)))
.collect();
// Cleanup explicitly: --no-kill leaves the survivors so they would
// pollute the next serial test.
for name in &names {
kill_tmux(&sock, name);
}
for (name, is_alive) in &alive {
assert!(
*is_alive,
"tmux session '{}' must survive --no-kill archive (#1868)",
name
);
}
let post_archive = read_sessions_json(&h);
let archived_at = post_archive[0]["archived_at"].as_str();
assert!(
archived_at.is_some() && !archived_at.unwrap().is_empty(),
"session must still be archived on disk even with --no-kill: archived_at = {:?}",
archived_at
);
}
/// Locks the #2179 widened TUI bulk-archive teardown (#2186). Archiving a whole
/// group runs the tmux teardown off-thread (`std::thread::spawn` +
/// `catch_unwind`, mirroring `force_remove_session`) while the persist stays on
@@ -509,3 +296,190 @@ fn test_tui_bulk_archive_group_tears_down_all_tmux_off_thread() {
);
}
}
/// Locks #1868: archive kills all four tmux session kinds. Pre-creates real
/// sessions, runs the CLI, asserts each kind is gone.
#[test]
#[parallel]
fn test_cli_archive_kills_agent_and_terminal_tmux_sessions() {
require_tmux!();
let h = TuiTestHarness::new("cli_archive_full_teardown");
let project = h.project_path();
let add_output = h.run_cli(&["add", project.to_str().unwrap(), "-t", "ArchiveTeardown"]);
assert!(
add_output.status.success(),
"aoe add failed: {}",
String::from_utf8_lossy(&add_output.stderr)
);
let sessions = read_sessions_json(&h);
let session_id = sessions[0]["id"]
.as_str()
.expect("session should have id")
.to_string();
let truncated_id = session_id[..8.min(session_id.len())].to_string();
let agent_tmux_name = format!(
"{}ArchiveTeardown_{}",
agent_of_empires::tmux::SESSION_PREFIX,
truncated_id
);
let terminal_tmux_name =
agent_of_empires::tmux::TerminalSession::generate_name(&session_id, "ArchiveTeardown");
// Pre-create both tmux kinds so archive can find and kill them. Created
// on the harness's tmux socket (`AOE_TMUX_SOCKET`), the same one the CLI
// resolves (#2608), so archive's teardown sweeps them.
let sock = h.home_path().join("tmux.sock");
let names = [&agent_tmux_name, &terminal_tmux_name];
for name in names {
let create = Command::new("tmux")
.arg("-S")
.arg(&sock)
.args([
"new-session",
"-d",
"-s",
name,
"-x",
"80",
"-y",
"24",
"sleep",
"600",
])
.output()
.expect("tmux new-session");
assert!(
create.status.success(),
"failed to create tmux session {}: {}",
name,
String::from_utf8_lossy(&create.stderr)
);
}
let archive_output = h.run_cli(&["session", "archive", &session_id]);
assert!(
archive_output.status.success(),
"aoe session archive failed: {}",
String::from_utf8_lossy(&archive_output.stderr)
);
let alive: Vec<(&&String, bool)> = names
.iter()
.map(|n| (n, tmux_has_session(&sock, n)))
.collect();
// Cleanup BEFORE asserting so a single failure cannot leak survivors
// into the next serial test.
for (name, is_alive) in &alive {
if *is_alive {
kill_tmux(&sock, name);
}
}
for (name, is_alive) in &alive {
assert!(
!is_alive,
"tmux session '{}' must be killed by archive (#1868)",
name
);
}
}
/// Locks the widened `--no-kill` semantic from #1868: skip ALL tmux
/// teardown. The agent assertion is the pre/post differentiator.
#[test]
#[parallel]
fn test_cli_archive_no_kill_preserves_all_tmux_sessions() {
require_tmux!();
let h = TuiTestHarness::new("cli_archive_no_kill");
let project = h.project_path();
let add_output = h.run_cli(&["add", project.to_str().unwrap(), "-t", "ArchiveNoKill"]);
assert!(
add_output.status.success(),
"aoe add failed: {}",
String::from_utf8_lossy(&add_output.stderr)
);
let sessions = read_sessions_json(&h);
let session_id = sessions[0]["id"]
.as_str()
.expect("session should have id")
.to_string();
let truncated_id = session_id[..8.min(session_id.len())].to_string();
let agent_tmux_name = format!(
"{}ArchiveNoKill_{}",
agent_of_empires::tmux::SESSION_PREFIX,
truncated_id
);
let terminal_tmux_name =
agent_of_empires::tmux::TerminalSession::generate_name(&session_id, "ArchiveNoKill");
let sock = h.home_path().join("tmux.sock");
let names = [&agent_tmux_name, &terminal_tmux_name];
for name in names {
let create = Command::new("tmux")
.arg("-S")
.arg(&sock)
.args([
"new-session",
"-d",
"-s",
name,
"-x",
"80",
"-y",
"24",
"sleep",
"600",
])
.output()
.expect("tmux new-session");
assert!(
create.status.success(),
"failed to create tmux session {}: {}",
name,
String::from_utf8_lossy(&create.stderr)
);
}
let archive_output = h.run_cli(&["session", "archive", &session_id, "--no-kill"]);
assert!(
archive_output.status.success(),
"aoe session archive --no-kill failed: {}",
String::from_utf8_lossy(&archive_output.stderr)
);
let alive: Vec<(&&String, bool)> = names
.iter()
.map(|n| (n, tmux_has_session(&sock, n)))
.collect();
// Cleanup explicitly: --no-kill leaves the survivors so they would
// pollute the next serial test.
for name in &names {
kill_tmux(&sock, name);
}
for (name, is_alive) in &alive {
assert!(
*is_alive,
"tmux session '{}' must survive --no-kill archive (#1868)",
name
);
}
let post_archive = read_sessions_json(&h);
let archived_at = post_archive[0]["archived_at"].as_str();
assert!(
archived_at.is_some() && !archived_at.unwrap().is_empty(),
"session must still be archived on disk even with --no-kill: archived_at = {:?}",
archived_at
);
}
+29 -31
View File
@@ -137,15 +137,13 @@ fn kill_tmux(sock: &std::path::Path, name: &str) {
.output();
}
/// Pre-create a tool sub-session whose name matches what
/// `kill_ancillary_tmux_sessions()` sweeps for `session_id` (by `_<id8>`
/// Pre-create an ancillary (paired-terminal) sub-session whose name matches
/// what `kill_ancillary_tmux_sessions()` sweeps for `session_id` (by `_<id8>`
/// suffix). Created on the harness's tmux socket (`AOE_TMUX_SOCKET`), the same
/// one the daemon resolves (#2608), so the daemon's teardown sweeps it.
/// Mirrors the precedent in `archive_restore.rs`.
fn create_tool_session(sock: &std::path::Path, session_id: &str, title: &str) -> String {
let name = agent_of_empires::tmux::ToolSession::new(session_id, title, "tooltest")
.session_name()
.to_string();
fn create_ancillary_session(sock: &std::path::Path, session_id: &str, title: &str) -> String {
let name = agent_of_empires::tmux::TerminalSession::generate_name(session_id, title);
let create = Command::new("tmux")
.arg("-S")
.arg(sock)
@@ -165,7 +163,7 @@ fn create_tool_session(sock: &std::path::Path, session_id: &str, title: &str) ->
.expect("tmux new-session");
assert!(
create.status.success(),
"failed to create tool tmux session {name}: {}",
"failed to create ancillary tmux session {name}: {}",
String::from_utf8_lossy(&create.stderr)
);
name
@@ -283,43 +281,43 @@ fn setup(h: &mut TuiTestHarness, title: &str) -> (u16, String, String) {
let session_id = parse_session_id(&String::from_utf8_lossy(&add.stdout));
prompt_until_accepted(h, &session_id, Duration::from_secs(30));
// Worker is live and registered. Pre-create the tool sub-session now so
// archive's `kill_ancillary_tmux_sessions()` has something to find.
let tool_name = create_tool_session(&h.home_path().join("tmux.sock"), &session_id, title);
// Worker is live and registered. Pre-create the ancillary sub-session now
// so archive's `kill_ancillary_tmux_sessions()` has something to find.
let aux_name = create_ancillary_session(&h.home_path().join("tmux.sock"), &session_id, title);
(port, session_id, tool_name)
(port, session_id, aux_name)
}
/// `kill_pane = true`: worker shut down, tool sub-session killed, transcript
/// preserved (no `session/delete`), session row archived on disk.
/// `kill_pane = true`: worker shut down, ancillary sub-session killed,
/// transcript preserved (no `session/delete`), session row archived on disk.
#[test]
#[parallel]
fn archive_kills_worker_and_tool_session() {
fn archive_kills_worker_and_ancillary_session() {
require_tmux!();
require_node!();
let mut h = TuiTestHarness::new_in_tmp("archive_structured_kill");
let title = "ArchiveStructKill";
let (port, session_id, tool_name) = setup(&mut h, title);
let (port, session_id, aux_name) = setup(&mut h, title);
let sock = h.home_path().join("tmux.sock");
assert!(
tmux_has_session(&sock, &tool_name),
"precondition: tool session {tool_name} should exist before archive"
tmux_has_session(&sock, &aux_name),
"precondition: ancillary session {aux_name} should exist before archive"
);
archive_via_api(port, &session_id, true);
wait_for_worker_gone(&h, &session_id, Duration::from_secs(10));
let tool_alive = tmux_has_session(&sock, &tool_name);
let aux_alive = tmux_has_session(&sock, &aux_name);
// Clean up before asserting so a failure can't leak into the next test.
if tool_alive {
kill_tmux(&sock, &tool_name);
if aux_alive {
kill_tmux(&sock, &aux_name);
}
assert!(
!tool_alive,
"tool sub-session {tool_name} must be killed by archive with kill_pane=true"
!aux_alive,
"ancillary sub-session {aux_name} must be killed by archive with kill_pane=true"
);
assert_no_session_delete(&h);
@@ -330,21 +328,21 @@ fn archive_kills_worker_and_tool_session() {
}
/// `kill_pane = false`: the worker is STILL shut down (unconditional), but the
/// tool sub-session survives. Transcript preserved, session row archived.
/// ancillary sub-session survives. Transcript preserved, session row archived.
#[test]
#[parallel]
fn archive_no_kill_shuts_worker_but_keeps_tool_session() {
fn archive_no_kill_shuts_worker_but_keeps_ancillary_session() {
require_tmux!();
require_node!();
let mut h = TuiTestHarness::new_in_tmp("archive_structured_nokill");
let title = "ArchiveStructNoKill";
let (port, session_id, tool_name) = setup(&mut h, title);
let (port, session_id, aux_name) = setup(&mut h, title);
let sock = h.home_path().join("tmux.sock");
assert!(
tmux_has_session(&sock, &tool_name),
"precondition: tool session {tool_name} should exist before archive"
tmux_has_session(&sock, &aux_name),
"precondition: ancillary session {aux_name} should exist before archive"
);
archive_via_api(port, &session_id, false);
@@ -352,12 +350,12 @@ fn archive_no_kill_shuts_worker_but_keeps_tool_session() {
// Worker shutdown is unconditional, so it must be gone even with kill_pane=false.
wait_for_worker_gone(&h, &session_id, Duration::from_secs(10));
let tool_alive = tmux_has_session(&sock, &tool_name);
let aux_alive = tmux_has_session(&sock, &aux_name);
// The survivor would pollute the next serial test; kill it before asserting.
kill_tmux(&sock, &tool_name);
kill_tmux(&sock, &aux_name);
assert!(
tool_alive,
"tool sub-session {tool_name} must survive archive with kill_pane=false"
aux_alive,
"ancillary sub-session {aux_name} must survive archive with kill_pane=false"
);
assert_no_session_delete(&h);
-1
View File
@@ -56,6 +56,5 @@ mod resume_fallback;
mod serve;
mod settings;
mod structured_tui_flows_e2e;
mod tool_sessions;
mod tui_launch;
mod unified_view;
-389
View File
@@ -1,389 +0,0 @@
//! E2E coverage for the [tools.*] feature: picker dialog, command-palette
//! integration, the invalid-hotkey info dialog, and the full attach +
//! cleanup roundtrip against a real agent session.
use serial_test::parallel;
use std::fs;
use std::path::Path;
use std::process::Command;
use std::time::{Duration, Instant};
use crate::harness::{app_dir_in, require_tmux, TuiTestHarness};
/// Append a `[tools.*]` block to the harness's pre-seeded config.toml.
fn append_tools_config(h: &TuiTestHarness, body: &str) {
let path = app_dir_in(h.home_path()).join("config.toml");
let existing = fs::read_to_string(&path).expect("read pre-seeded config.toml");
fs::write(&path, format!("{existing}\n{body}\n")).expect("write tools config");
}
/// List tmux session names on a specific socket. Tool sessions spawned by
/// `aoe` while running inside the harness's tmux land on the harness's
/// per-test socket (because `TMUX` env points there), so callers pass
/// the harness's socket here to verify creation and sweep.
fn list_tmux_sessions_on(socket: &std::path::Path) -> Vec<String> {
let output = Command::new("tmux")
.arg("-S")
.arg(socket)
.args(["list-sessions", "-F", "#{session_name}"])
.output();
match output {
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
.lines()
.map(String::from)
.collect(),
_ => Vec::new(),
}
}
/// Defensive teardown for any tool tmux session that survived a test
/// (e.g., when the test panics before the cleanup assertion).
fn kill_lingering_tool_sessions_on(socket: &std::path::Path, prefix_marker: &str) {
for name in list_tmux_sessions_on(socket) {
if name.starts_with("aoe_dev_tool_") && name.contains(prefix_marker) {
let _ = Command::new("tmux")
.arg("-S")
.arg(socket)
.args(["kill-session", "-t", &name])
.output();
}
}
}
fn shell_quote_path(path: &Path) -> String {
format!("'{}'", path.to_string_lossy().replace('\'', "'\\''"))
}
fn wait_for_file_contents(path: &Path, timeout: Duration) -> String {
let start = Instant::now();
loop {
if let Ok(contents) = fs::read_to_string(path) {
if !contents.trim().is_empty() {
return contents;
}
}
if start.elapsed() >= timeout {
panic!("timed out waiting for {}", path.display());
}
std::thread::sleep(Duration::from_millis(50));
}
}
#[test]
#[parallel]
fn test_tool_picker_lists_configured_tools() {
require_tmux!();
let mut h = TuiTestHarness::new("tool_picker_list");
append_tools_config(
&h,
r#"
[tools.lazygit]
command = "lazygit"
hotkey = "Alt+g"
[tools.yazi]
command = "yazi"
"#,
);
h.spawn_tui();
h.wait_for(" aoe ");
h.send_keys("\\;");
h.wait_for("Tool Sessions");
h.assert_screen_contains("lazygit");
h.assert_screen_contains("yazi");
// Footer hint added in this PR.
h.assert_screen_contains("Enter");
h.assert_screen_contains("Esc");
// Re-press ; to close (toggle behavior added in this PR).
h.send_keys("\\;");
h.wait_for_absent("Tool Sessions", Duration::from_secs(5));
}
#[test]
#[parallel]
fn test_tool_picker_does_not_open_with_no_tools_configured() {
require_tmux!();
let mut h = TuiTestHarness::new("tool_picker_empty");
h.spawn_tui();
h.wait_for(" aoe ");
h.send_keys("\\;");
// With zero [tools.*] entries the picker is suppressed; the screen
// should still be on the home view.
std::thread::sleep(Duration::from_millis(200));
h.assert_screen_not_contains("Tool Sessions");
}
#[test]
#[parallel]
fn test_command_palette_includes_tool_entries() {
require_tmux!();
let mut h = TuiTestHarness::new("tool_palette_entry");
append_tools_config(
&h,
r#"
[tools.lazygit]
command = "lazygit"
hotkey = "Alt+g"
"#,
);
h.spawn_tui();
h.wait_for(" aoe ");
h.send_keys("C-k");
h.wait_for("Commands");
h.type_text("lazyg");
std::thread::sleep(Duration::from_millis(150));
h.assert_screen_contains("Open tool: lazygit");
}
#[test]
#[parallel]
fn test_background_tool_runs_without_tmux_session_or_preview_switch() {
require_tmux!();
let mut h = TuiTestHarness::new("tool_background_run");
let output_path = h.home_path().join("background-tool-pwd.txt");
append_tools_config(
&h,
&format!(
r#"
[tools.bgpwd]
command = "pwd > {}"
hotkey = "Alt+b"
background = true
"#,
shell_quote_path(&output_path),
),
);
let project = h.project_path();
let expected_project = fs::canonicalize(&project).expect("canonicalize project path");
let add = h.run_cli(&[
"add",
project.to_str().unwrap(),
"-t",
"BackgroundToolSession",
]);
assert!(
add.status.success(),
"aoe add failed: {}",
String::from_utf8_lossy(&add.stderr)
);
let sessions_path = app_dir_in(h.home_path()).join("profiles/default/sessions.json");
let sessions_str = fs::read_to_string(&sessions_path).expect("read sessions.json");
let sessions: serde_json::Value =
serde_json::from_str(&sessions_str).expect("parse sessions.json");
let session_id = sessions[0]["id"]
.as_str()
.expect("session id present in sessions.json")
.to_string();
let id_suffix = &session_id[..session_id.len().min(8)];
let harness_sock = h.home_path().join("tmux.sock");
kill_lingering_tool_sessions_on(&harness_sock, id_suffix);
h.spawn_tui();
h.wait_for("BackgroundToolSession");
h.send_keys("C-k");
h.wait_for("Commands");
h.type_text("bgpwd");
h.wait_for("Run: bgpwd");
h.send_keys("Enter");
let contents = wait_for_file_contents(&output_path, Duration::from_secs(5));
assert_eq!(contents.trim(), expected_project.to_string_lossy());
h.assert_screen_not_contains("Tool: bgpwd");
fs::remove_file(&output_path).expect("remove palette output");
h.send_keys("M-b");
let contents = wait_for_file_contents(&output_path, Duration::from_secs(5));
assert_eq!(contents.trim(), expected_project.to_string_lossy());
h.assert_screen_not_contains("Tool: bgpwd");
fs::remove_file(&output_path).expect("remove hotkey output");
h.send_keys("\\;");
h.wait_for("Tool Sessions");
h.assert_screen_contains("[bg]");
h.send_keys("Enter");
let contents = wait_for_file_contents(&output_path, Duration::from_secs(5));
assert_eq!(contents.trim(), expected_project.to_string_lossy());
h.assert_screen_not_contains("Tool: bgpwd");
let sessions = list_tmux_sessions_on(&harness_sock);
assert!(
!sessions
.iter()
.any(|s| s.starts_with("aoe_dev_tool_") && s.contains(id_suffix)),
"background tool should not create a tmux tool session. sessions seen: {:?}",
sessions
);
}
#[test]
#[parallel]
fn test_invalid_hotkey_surfaces_info_dialog() {
require_tmux!();
let mut h = TuiTestHarness::new("tool_invalid_hotkey");
append_tools_config(
&h,
r#"
[tools.bad]
command = "echo hi"
hotkey = "Ctrl+x"
"#,
);
h.spawn_tui();
// The startup info dialog should mention the broken entry.
h.wait_for("Tool hotkey config errors");
h.assert_screen_contains("bad");
h.assert_screen_contains("Ctrl+x");
}
#[test]
#[parallel]
fn test_tool_session_full_attach_and_cleanup_roundtrip() {
require_tmux!();
// Marker we'll grep for in the preview pane to prove the tool ran in
// the right working directory and the preview cache captured it.
const MARKER: &str = "TOOL_OUTPUT_ROUNDTRIP_MARKER";
let mut h = TuiTestHarness::new("tool_roundtrip");
append_tools_config(
&h,
&format!(
r#"
[tools.echotool]
command = "while true; do echo {MARKER}; sleep 0.5; done"
hotkey = "Alt+t"
"#
),
);
// Create an agent session for the tool to attach to. The harness's
// claude stub exits immediately, but the session row stays in the
// list (Idle status). That's all we need to drive the tool flow.
let project = h.project_path();
let add = h.run_cli(&["add", project.to_str().unwrap(), "-t", "RoundtripSession"]);
assert!(
add.status.success(),
"aoe add failed: {}",
String::from_utf8_lossy(&add.stderr)
);
// Find the session ID for the later remove + cleanup-sweep assertion.
let sessions_path = app_dir_in(h.home_path()).join("profiles/default/sessions.json");
let sessions_str = fs::read_to_string(&sessions_path).expect("read sessions.json");
let sessions: serde_json::Value =
serde_json::from_str(&sessions_str).expect("parse sessions.json");
let session_id = sessions[0]["id"]
.as_str()
.expect("session id present in sessions.json")
.to_string();
let id_suffix = &session_id[..session_id.len().min(8)];
// The harness's tmux server uses a per-test socket. Tool tmux sessions
// spawned by aoe (running inside the harness's tmux) inherit `TMUX` and
// land on the *same* socket, not the system default. We inspect and
// clean up on that socket.
let harness_sock = h.home_path().join("tmux.sock");
// Defensive: kill any stale tool sessions from a previous aborted run.
kill_lingering_tool_sessions_on(&harness_sock, id_suffix);
h.spawn_tui();
h.wait_for("RoundtripSession");
// Press the configured hotkey (Alt+t). tmux's send-keys grammar
// names Alt-modified keys as `M-<key>`.
h.send_keys("M-t");
// Title flips to the new "Tool: <name>" prefix added in this PR.
h.wait_for("Tool: echotool");
// Pressing Enter triggers AttachToolSession, which (a) creates the
// tool tmux session via `tmux new-session` running our command, then
// (b) tries to switch-client / attach-session. Both attach paths
// return errors when invoked from inside the harness's existing
// tmux session ("sessions should be nested with care"), but that
// error is swallowed and the tool tmux session itself is created.
// After the failed attach, the TUI redraws and the preview cache
// picks up the tool's stdout on its next 250ms refresh.
h.send_keys("Enter");
// Wait for the preview cache to pick up the tool's output. The cache
// refreshes only when the TUI redraws (every 120ms when there's an
// animated spinner, every 5s on disk refresh, or on any key event).
h.wait_for(MARKER);
// Esc returns to the structured view.
h.send_keys("Escape");
h.wait_for_absent("Tool: echotool", Duration::from_secs(5));
// The tool tmux session should still exist on the harness socket
// until we remove the parent agent session.
let pre_remove = list_tmux_sessions_on(&harness_sock);
assert!(
pre_remove
.iter()
.any(|s| s.starts_with("aoe_dev_tool_") && s.contains(id_suffix)),
"expected a live aoe_dev_tool_* session matching id suffix {} before removal. \
sessions seen: {:?}",
id_suffix,
pre_remove
);
// Quit the TUI so the removal CLI can write sessions.json without
// racing the TUI's poller. `q` opens the quit confirmation (#1569),
// so confirm with `y` to actually exit.
h.send_keys("q");
h.wait_for("Quit Agent of Empires");
h.send_keys("y");
h.wait_for_exit(Duration::from_secs(5));
// Remove the agent session. `perform_deletion` invokes
// `kill_all_tool_sessions_for_id`, which runs `tmux list-sessions` /
// `kill-session` against the tmux socket aoe resolves. aoe now routes
// every tmux call through an explicit `-S <socket>` (#2608), so point
// `AOE_TMUX_SOCKET` at the harness's per-test socket to exercise the
// sweep there instead of aoe's own app-dir socket.
let aoe_binary = env!("CARGO_BIN_EXE_aoe");
let remove = Command::new(aoe_binary)
.args(["remove", &session_id, "--force"])
.env("HOME", h.home_path())
.env("XDG_CONFIG_HOME", h.home_path().join(".config"))
.env("AOE_TMUX_SOCKET", &harness_sock)
.env_remove("AGENT_OF_EMPIRES_DEBUG")
.env_remove("AOE_LOG_LEVEL")
.output()
.expect("run aoe remove");
assert!(
remove.status.success(),
"aoe remove failed: {}",
String::from_utf8_lossy(&remove.stderr)
);
// Verify the sweep landed.
let post_remove = list_tmux_sessions_on(&harness_sock);
let leaked: Vec<_> = post_remove
.iter()
.filter(|s| s.starts_with("aoe_dev_tool_") && s.contains(id_suffix))
.collect();
assert!(
leaked.is_empty(),
"tool sessions leaked after `aoe remove`: {:?}",
leaked
);
// Belt-and-suspenders: clean up anything else we created, in case
// the assertion above passes but other state hangs around.
kill_lingering_tool_sessions_on(&harness_sock, id_suffix);
}
-1
View File
@@ -43,7 +43,6 @@ fn spawn_config(
fork_from: None,
seed_history_replay: false,
artifact_dir: None,
sandbox_info: None,
source_profile: None,
}
}
-4
View File
@@ -166,7 +166,6 @@ async fn attach_in_flight_synthesizes_reattach_idle_stopped() {
"test-acp-session-id".into(),
true, // in_flight_turn
AcpSessionId("midturn-true".into()),
None,
"claude".into(),
None,
)
@@ -203,7 +202,6 @@ async fn attach_idle_session_does_not_synthesize_stopped() {
"test-acp-session-id".into(),
false, // NOT in flight
AcpSessionId("midturn-false".into()),
None,
"claude".into(),
None,
)
@@ -250,7 +248,6 @@ async fn attach_in_flight_disarms_after_first_inbound_notification() {
session_id.into(),
true, // in_flight_turn
AcpSessionId("midturn-disarm".into()),
None,
"claude".into(),
None,
)
@@ -294,7 +291,6 @@ async fn socket_transport_round_trips_prompt_via_attach() {
preseed.into(),
false, // not in flight; this is a fresh round-trip
AcpSessionId("roundtrip".into()),
None,
"claude".into(),
None,
)
-1
View File
@@ -42,7 +42,6 @@ fn spawn_config_with_shim_env(shim: PathBuf, env: Vec<(String, String)>) -> Spaw
fork_from: None,
seed_history_replay: false,
artifact_dir: None,
sandbox_info: None,
source_profile: None,
}
}
-6
View File
@@ -155,7 +155,6 @@ async fn silent_orphan_fires_on_cost_then_silence() {
preseed.to_string(),
false,
AcpSessionId("silent-orphan-positive".into()),
None,
"claude".into(),
None,
)
@@ -216,7 +215,6 @@ async fn silent_orphan_suppressed_during_normal_turn() {
preseed.to_string(),
false,
AcpSessionId("silent-orphan-negative".into()),
None,
"claude".into(),
None,
)
@@ -278,7 +276,6 @@ async fn silent_orphan_disabled_by_zero_grace() {
preseed.to_string(),
false,
AcpSessionId("silent-orphan-disabled".into()),
None,
"claude".into(),
None,
)
@@ -336,7 +333,6 @@ async fn silent_orphan_suppressed_during_async_agent_wait() {
preseed.to_string(),
false,
AcpSessionId("silent-orphan-async-agent".into()),
None,
"claude".into(),
None,
)
@@ -395,7 +391,6 @@ async fn silent_orphan_suppressed_during_background_bash() {
preseed.to_string(),
false,
AcpSessionId("silent-orphan-background-bash".into()),
None,
"claude".into(),
None,
)
@@ -448,7 +443,6 @@ async fn silent_orphan_suppressed_during_scheduled_wakeup() {
preseed.to_string(),
false,
AcpSessionId("silent-orphan-wakeup".into()),
None,
"claude".into(),
None,
)

Some files were not shown because too many files have changed in this diff Show More