diff --git a/.github/actions/setup-nix/action.yaml b/.github/actions/setup-nix/action.yaml index 0be29239..fbc3f501 100644 --- a/.github/actions/setup-nix/action.yaml +++ b/.github/actions/setup-nix/action.yaml @@ -29,6 +29,6 @@ runs: - name: Setup nix develop env shell: bash run: | - echo 'use flake' >.envrc + echo 'use flake .#ci' >.envrc - uses: HatsuneMiku3939/direnv-action@v1 # and avoid develop env being gc diff --git a/crates/coco-tui/src/e2e_tests/support.rs b/crates/coco-tui/src/e2e_tests/support.rs index f8864d6f..ac2c7077 100644 --- a/crates/coco-tui/src/e2e_tests/support.rs +++ b/crates/coco-tui/src/e2e_tests/support.rs @@ -676,9 +676,6 @@ fn resolve_coco_bin_from_env() -> Option { if let Ok(path) = env::var("COCO_TEST_BIN") { return Some(PathBuf::from(path)); } - if let Ok(path) = env::var("COCO_TUI_BIN") { - return Some(PathBuf::from(path)); - } if let Ok(path) = env::var("CARGO_BIN_EXE_coco") { return Some(PathBuf::from(path)); } @@ -703,7 +700,7 @@ pub(crate) fn coco_binary() -> PathBuf { let path = resolve_coco_bin_from_env().unwrap_or_else(resolve_coco_bin_from_target); assert!( path.exists(), - "coco binary not found at {:?}; build `cargo build -p coco-tui --bin coco` or set COCO_TUI_BIN/COCO_TEST_BIN", + "coco binary not found at {:?}; build `cargo build -p coco-tui --bin coco` or set COCO_TEST_BIN", path ); path diff --git a/crates/coco-tui/src/main.rs b/crates/coco-tui/src/main.rs index ec47dcc6..2f09bb3d 100644 --- a/crates/coco-tui/src/main.rs +++ b/crates/coco-tui/src/main.rs @@ -134,11 +134,9 @@ impl TryFrom for ClientCommand { command, }, Commands::Mcp { args } => ClientCommand::Mcp { args }, - Commands::Combo(ComboCommands::Run { name, args }) => ClientCommand::ComboRun { - name, - args, - ignore_workspace_scripts: false, - }, + Commands::Combo(ComboCommands::Run { name, args }) => { + ClientCommand::ComboRun { name, args } + } }; Ok(command) } @@ -175,14 +173,7 @@ async fn main() -> Result<()> { if should_handle_client { match ClientCommand::try_from(command) { - Ok(mut command) => { - if let ClientCommand::ComboRun { - ignore_workspace_scripts, - .. - } = &mut command - { - *ignore_workspace_scripts = args.ignore_workspace_scripts; - } + Ok(command) => { init_client_logging(&program, &command); return handle_client_command(&program, &command_name, command) .await diff --git a/flake.lock b/flake.lock index c8c4df1f..49e0153d 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,27 @@ { "nodes": { + "blueprint": { + "inputs": { + "nixpkgs": [ + "llm-agents", + "nixpkgs" + ], + "systems": "systems" + }, + "locked": { + "lastModified": 1769353768, + "narHash": "sha256-zI+7cbMI4wMIR57jMjDSEsVb3grapTnURDxxJPYFIW0=", + "owner": "numtide", + "repo": "blueprint", + "rev": "c7da5c70ad1c9b60b6f5d4f674fbe205d48d8f6c", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "blueprint", + "type": "github" + } + }, "fenix": { "inputs": { "nixpkgs": [ @@ -21,7 +43,58 @@ "type": "github" } }, + "jail-nix": { + "locked": { + "lastModified": 1770418571, + "narHash": "sha256-EzQUbe1gwW/xpJoMuMeblWcjAEF+F92cz/enz0Mz/qo=", + "owner": "~alexdavid", + "repo": "jail.nix", + "rev": "c141cf8cc68617625b4a28a7d8ce0a35904815d5", + "type": "sourcehut" + }, + "original": { + "owner": "~alexdavid", + "repo": "jail.nix", + "type": "sourcehut" + } + }, + "llm-agents": { + "inputs": { + "blueprint": "blueprint", + "nixpkgs": "nixpkgs", + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1770435437, + "narHash": "sha256-QGFHw3wc08DgAt2GllBgrOnCcgXTMwOsokdfMDHVprI=", + "owner": "numtide", + "repo": "llm-agents.nix", + "rev": "36fb71277896585b319e56e8a98c265f24813e30", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "llm-agents.nix", + "type": "github" + } + }, "nixpkgs": { + "locked": { + "lastModified": 1770169770, + "narHash": "sha256-awR8qIwJxJJiOmcEGgP2KUqYmHG4v/z8XpL9z8FnT1A=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "aa290c9891fa4ebe88f8889e59633d20cc06a5f2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { "locked": { "lastModified": 1766025857, "narHash": "sha256-Lav5jJazCW4mdg1iHcROpuXqmM94BWJvabLFWaJVJp0=", @@ -40,7 +113,9 @@ "root": { "inputs": { "fenix": "fenix", - "nixpkgs": "nixpkgs", + "jail-nix": "jail-nix", + "llm-agents": "llm-agents", + "nixpkgs": "nixpkgs_2", "utils": "utils" } }, @@ -76,9 +151,45 @@ "type": "github" } }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "llm-agents", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1770228511, + "narHash": "sha256-wQ6NJSuFqAEmIg2VMnLdCnUc0b7vslUohqqGGD+Fyxk=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "337a4fe074be1042a35086f15481d763b8ddc0e7", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + }, "utils": { "inputs": { - "systems": "systems" + "systems": "systems_2" }, "locked": { "lastModified": 1731533236, diff --git a/flake.nix b/flake.nix index afe2c23c..39d3e016 100644 --- a/flake.nix +++ b/flake.nix @@ -4,12 +4,17 @@ nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; fenix.url = "github:nix-community/fenix"; fenix.inputs.nixpkgs.follows = "nixpkgs"; + + jail-nix.url = "sourcehut:~alexdavid/jail.nix"; + llm-agents.url = "github:numtide/llm-agents.nix"; }; outputs = { self, nixpkgs, utils, + jail-nix, + llm-agents, ... } @ inputs: utils.lib.eachDefaultSystem @@ -104,18 +109,26 @@ }; }; devShells = let - components = [ - "cargo" - "clippy" - "rust-src" - "rustc" - "rustfmt" - "llvm-tools" - "rust-analyzer" + jail = jail-nix.lib.init pkgs; + commonPackages = with pkgs; [ + bashInteractive + curl + wget + jq + git + which + ripgrep + gnugrep + gawkInteractive + ps + findutils + gzip + unzip + gnutar + diffutils ]; - packages = with pkgs; + devPackages = with pkgs; [ - # Development grcov prek @@ -126,21 +139,53 @@ run-test run-cov ]); + nativeBuildInputs = with pkgs; ([ + (fenix.stable.withComponents [ + "cargo" + "clippy" + "rust-src" + "rustc" + "rustfmt" + "llvm-tools" + "rust-analyzer" + ]) + ] + ++ lib.optionals stdenv.isLinux [pkg-config]); + commonJailOptions = with jail.combinators; [ + network + time-zone + no-new-session + mount-cwd + ]; + codex = llm-agents.packages.${system}.codex; + jailed-codex = jail "jailed-codex" codex (with jail.combinators; ( + commonJailOptions + ++ [ + (readwrite (noescape "~/.codex")) + (add-pkg-deps [pkgs.stdenv.cc]) + (add-pkg-deps commonPackages) + (add-pkg-deps nativeBuildInputs) + (add-pkg-deps devPackages) + ] + )); + packages = commonPackages ++ devPackages ++ lib.optionals pkgs.stdenv.isLinux [jailed-codex]; + shellHook = '' + # Unset SOURCE_DATE_EPOCH to prevent reproducible build timestamps during development. + # This allows timestamps to reflect the current time, which is useful for development workflows. + unset SOURCE_DATE_EPOCH + ''; in rec { default = stable; - stable = pkgs.mkShell { - nativeBuildInputs = with pkgs; ([ - (fenix.stable.withComponents components) - ] - ++ lib.optionals stdenv.isLinux [pkg-config]); + ci = pkgs.mkShell { + inherit nativeBuildInputs; + inherit shellHook; + packages = devPackages; + }; + stable = pkgs.mkShell { + inherit nativeBuildInputs; inherit packages; - - shellHook = '' - # Unset SOURCE_DATE_EPOCH to prevent reproducible build timestamps during development. - # This allows timestamps to reflect the current time, which is useful for development workflows. - unset SOURCE_DATE_EPOCH - ''; + inherit shellHook; }; }; } diff --git a/src/cli.rs b/src/cli.rs index a6797612..bc9ffa7a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -22,7 +22,6 @@ pub enum ClientCommand { ComboRun { name: String, args: Vec, - ignore_workspace_scripts: bool, }, Record { wrap_result: bool, @@ -69,11 +68,7 @@ pub async fn handle_client_command( ClientCommand::Reply { fields } => crate::cmd::handle_reply(fields) .await .whatever_context("failed to handle reply"), - ClientCommand::ComboRun { - name, - args, - ignore_workspace_scripts, - } => crate::cmd::handle_combo_run(name, args, ignore_workspace_scripts) + ClientCommand::ComboRun { name, args } => crate::cmd::handle_combo_run(name, args) .await .whatever_context("failed to handle combo run"), ClientCommand::Record { diff --git a/src/cmd/combo.rs b/src/cmd/combo.rs index 5e772642..348635ec 100644 --- a/src/cmd/combo.rs +++ b/src/cmd/combo.rs @@ -1,19 +1,11 @@ -use std::{path::Path, path::PathBuf, process::Stdio, time::Duration}; - use snafu::prelude::*; -use tokio::{process::Command, time::Instant}; -use tracing::{debug, info, warn}; use crate::{ - ComboRunPayload, ComboRunResult, RunComboOutput, SESSION_SOCKET_ENV, SessionEnv, - SessionSocketClient, error::Result, + ComboRunPayload, ComboRunResult, RunComboOutput, SESSION_SOCKET_ENV, SessionSocketClient, + error::Result, }; -pub async fn handle_combo_run( - name: String, - args: Vec, - ignore_workspace_scripts: bool, -) -> Result<()> { +pub async fn handle_combo_run(name: String, args: Vec) -> Result<()> { ensure_whatever!(!name.trim().is_empty(), "combo name is required"); let payload = ComboRunPayload { run_id: new_run_id(), @@ -25,29 +17,20 @@ pub async fn handle_combo_run( .await .whatever_context(format!("failed to read {SESSION_SOCKET_ENV}"))?; - match client { - Some(client) => { - let result = run_with_client(client, payload).await?; - emit_result(&result)?; - if !result.success { - let error = result - .error - .clone() - .unwrap_or_else(|| "combo run failed".to_string()); - whatever!("{error}"); - } - } - None => { - let (result, mut child) = run_with_tui(payload, ignore_workspace_scripts).await?; - wait_for_tui_exit(&mut child).await?; - if !result.success { - let error = result - .error - .clone() - .unwrap_or_else(|| "combo run failed".to_string()); - whatever!("{error}"); - } - } + let Some(client) = client else { + whatever!( + "{SESSION_SOCKET_ENV} is not set or session socket unavailable; start coco TUI first" + ); + }; + + let result = run_with_client(client, payload).await?; + emit_result(&result)?; + if !result.success { + let error = result + .error + .clone() + .unwrap_or_else(|| "combo run failed".to_string()); + whatever!("{error}"); } Ok(()) } @@ -67,88 +50,6 @@ async fn run_with_client( Ok(result) } -async fn run_with_tui( - payload: ComboRunPayload, - ignore_workspace_scripts: bool, -) -> Result<(ComboRunResult, tokio::process::Child)> { - let session_env = SessionEnv::builder() - .build() - .whatever_context("failed to build session env")?; - let socket_path = session_env.socket_path().to_path_buf(); - - let mut child = spawn_tui(&session_env, ignore_workspace_scripts).await?; - let client = wait_for_session(&socket_path, &mut child).await?; - info!(?socket_path, "session socket ready for combo run"); - let result = run_with_client(client, payload).await?; - Ok((result, child)) -} - -async fn spawn_tui( - session_env: &SessionEnv, - ignore_workspace_scripts: bool, -) -> Result { - let tui_bin = resolve_tui_command(); - let mut cmd = Command::new(&tui_bin); - if ignore_workspace_scripts { - cmd.arg("--ignore-workspace-scripts"); - } - cmd.env(session_env.socket_env_name(), session_env.socket_path()); - cmd.stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()); - let child = cmd - .spawn() - .whatever_context("failed to start coco TUI process")?; - Ok(child) -} - -async fn wait_for_session( - socket_path: &Path, - child: &mut tokio::process::Child, -) -> Result { - let deadline = Instant::now() + Duration::from_secs(10); - loop { - match SessionSocketClient::connect(socket_path).await { - Ok(client) => return Ok(client), - Err(err) => { - debug!(?err, "failed to connect to session socket"); - } - } - - match child.try_wait() { - Ok(Some(status)) => { - whatever!( - "TUI process exited before session socket was ready: {status} (set COCO_TUI_BIN if needed)" - ); - } - Ok(None) => {} - Err(err) => { - warn!(?err, "failed to check TUI process status"); - } - } - - if Instant::now() >= deadline { - whatever!( - "timed out waiting for session socket at {:?} (set COCO_TUI_BIN if needed)", - socket_path - ); - } - - tokio::time::sleep(Duration::from_millis(200)).await; - } -} - -async fn wait_for_tui_exit(child: &mut tokio::process::Child) -> Result<()> { - let status = child - .wait() - .await - .whatever_context("failed to wait for TUI process")?; - if !status.success() { - warn!(?status, "TUI exited with non-zero status"); - } - Ok(()) -} - fn emit_result(result: &ComboRunResult) -> Result<()> { let output = RunComboOutput { success: result.success, @@ -163,12 +64,6 @@ fn emit_result(result: &ComboRunResult) -> Result<()> { Ok(()) } -fn resolve_tui_command() -> PathBuf { - std::env::var_os("COCO_TUI_BIN") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("coco")) -} - fn new_run_id() -> String { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -176,3 +71,26 @@ fn new_run_id() -> String { .unwrap_or(0); format!("run_{}_{}", std::process::id(), nanos) } + +#[cfg(test)] +mod tests { + use crate::test_utils::SessionSocketTestGuard; + + use super::*; + + #[tokio::test] + async fn combo_run_requires_existing_session_socket() { + let guard = SessionSocketTestGuard::acquire(); + guard.clear_env(); + guard.clear_global(); + + let err = handle_combo_run("demo".to_string(), Vec::new()) + .await + .expect_err("combo run should fail without session socket"); + let message = err.to_string(); + assert!( + message.contains("start coco TUI first"), + "unexpected error: {message}" + ); + } +} diff --git a/src/main.rs b/src/main.rs index 875588aa..051fcf97 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,9 +9,6 @@ use snafu::prelude::*; #[derive(Debug, Parser)] #[command(name = "coco", version, long_version = version::long_version(), about)] struct Args { - /// Ignore workspace combo scripts under .coco/combos - #[arg(long)] - ignore_workspace_scripts: bool, #[command(subcommand)] command: Commands, } @@ -103,7 +100,6 @@ async fn main() -> code_combo::Result<()> { }) => ClientCommand::ComboRun { name, args: combo_args, - ignore_workspace_scripts: args.ignore_workspace_scripts, }, Commands::Mcp { args } => ClientCommand::Mcp { args }, }; diff --git a/src/tools/bash.rs b/src/tools/bash.rs index 23470bb5..42c86ac5 100644 --- a/src/tools/bash.rs +++ b/src/tools/bash.rs @@ -58,27 +58,13 @@ pub(crate) fn extra_envs_for_bash_input(input: &Input<'_>) -> Vec<(String, Strin let Input::Starter(input) = input else { return Vec::new(); }; - let Ok(parsed) = serde_json::from_value::(input.clone()) else { + if serde_json::from_value::(input.clone()).is_err() { return Vec::new(); - }; - let mut envs = extra_envs_for_command(&parsed.command); - for (key, value) in parsed.env { - if value.is_empty() { - continue; - } - if !matches!(key.as_str(), "COCO_TUI_BIN") { - continue; - } - if let Some(existing) = envs.iter_mut().find(|(k, _)| *k == key) { - existing.1 = value; - } else { - envs.push((key, value)); - } } - envs + extra_envs_for_command() } -fn extra_envs_for_command(_command: &str) -> Vec<(String, String)> { +fn extra_envs_for_command() -> Vec<(String, String)> { let mut envs = Vec::new(); if let Some(path) = crate::global::session_socket_path() { let value = path.to_string_lossy().to_string(); @@ -86,11 +72,6 @@ fn extra_envs_for_command(_command: &str) -> Vec<(String, String)> { envs.push((SESSION_SOCKET_ENV.to_string(), value)); } } - if let Ok(value) = std::env::var("COCO_TUI_BIN") - && !value.is_empty() - { - envs.push(("COCO_TUI_BIN".to_string(), value)); - } envs }