diff --git a/Cargo.lock b/Cargo.lock index ddbc36b6..061eecd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -333,6 +333,7 @@ name = "client" version = "0.1.0" dependencies = [ "manifest", + "pod-command", "protocol", "serde_json", "tokio", @@ -2338,6 +2339,7 @@ dependencies = [ "manifest", "memory", "minijinja", + "pod-command", "pod-registry", "pod-store", "protocol", @@ -2357,6 +2359,10 @@ dependencies = [ "workflow", ] +[[package]] +name = "pod-command" +version = "0.1.0" + [[package]] name = "pod-registry" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index c49b8f61..6f8842cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/session-store", "crates/manifest", "crates/pod", + "crates/pod-command", "crates/pod-store", "crates/protocol", "crates/provider", @@ -33,6 +34,7 @@ manifest = { path = "crates/manifest" } lint-common = { path = "crates/lint-common" } memory = { path = "crates/memory" } pod = { path = "crates/pod" } +pod-command = { path = "crates/pod-command" } pod-registry = { path = "crates/pod-registry" } pod-store = { path = "crates/pod-store" } protocol = { path = "crates/protocol" } diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 7eccbe23..50a16c0b 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] protocol = { workspace = true } manifest = { workspace = true } +pod-command = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] } uuid = { workspace = true } diff --git a/crates/client/src/spawn.rs b/crates/client/src/spawn.rs index ad448a70..dcada57d 100644 --- a/crates/client/src/spawn.rs +++ b/crates/client/src/spawn.rs @@ -1,4 +1,4 @@ -//! `insomnia-pod` バイナリをサブプロセスとして立ち上げ、`INSOMNIA-READY` を待つ +//! Pod runtime command をサブプロセスとして立ち上げ、`INSOMNIA-READY` を待つ //! ハンドシェイク。 //! //! - 親プロセス (TUI / GUI / E2E) は profile/default/typed restore flags を @@ -15,6 +15,7 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::Duration; +use pod_command::PodRuntimeCommand; use tokio::process::Command; use uuid::Uuid; @@ -99,7 +100,7 @@ pub async fn spawn_pod(config: SpawnConfig, mut progress: F) -> Result PathBuf { - if let Ok(cmd) = std::env::var("INSOMNIA_POD_COMMAND") - && !cmd.is_empty() - { - return PathBuf::from(cmd); - } - PathBuf::from("insomnia-pod") -} - struct StderrTail { lines: std::collections::VecDeque, } diff --git a/crates/pod-command/Cargo.toml b/crates/pod-command/Cargo.toml new file mode 100644 index 00000000..527fc0f5 --- /dev/null +++ b/crates/pod-command/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "pod-command" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] diff --git a/crates/pod-command/src/lib.rs b/crates/pod-command/src/lib.rs new file mode 100644 index 00000000..53d6b5fa --- /dev/null +++ b/crates/pod-command/src/lib.rs @@ -0,0 +1,170 @@ +use std::ffi::{OsStr, OsString}; +use std::fmt; +use std::io; +use std::path::{Path, PathBuf}; + +pub const POD_COMMAND_OVERRIDE_ENV: &str = "INSOMNIA_POD_COMMAND"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PodRuntimeCommand { + pub program: PathBuf, + pub prefix_args: Vec, +} + +impl PodRuntimeCommand { + pub fn new(program: impl Into, prefix_args: Vec) -> Self { + Self { + program: program.into(), + prefix_args, + } + } + + pub fn executable_only(program: impl Into) -> Self { + Self::new(program, Vec::new()) + } + + pub fn for_current_exe() -> io::Result { + Ok(Self::for_executable(std::env::current_exe()?)) + } + + pub fn for_executable(program: impl Into) -> Self { + let program = program.into(); + let prefix_args = if is_legacy_pod_binary(&program) { + Vec::new() + } else { + vec![OsString::from("pod")] + }; + Self::new(program, prefix_args) + } + + /// Resolve the Pod runtime command used for subprocess launches. + /// + /// `INSOMNIA_POD_COMMAND` is intentionally executable-only: its value is + /// used as the program path without shell parsing and without the unified + /// `pod` prefix arg. That keeps existing development/test overrides safe + /// while the default path moves to `current_exe() + ["pod"]`. + pub fn resolve() -> io::Result { + if let Some(command) = Self::from_override_env() { + return Ok(command); + } + Self::for_current_exe() + } + + pub fn from_override_env() -> Option { + let raw = std::env::var_os(POD_COMMAND_OVERRIDE_ENV)?; + if raw.is_empty() { + return None; + } + Some(Self::executable_only(raw)) + } + + pub fn program(&self) -> &Path { + &self.program + } + + pub fn prefix_args(&self) -> &[OsString] { + &self.prefix_args + } + + pub fn argv_with(&self, args: I) -> Vec + where + I: IntoIterator, + S: Into, + { + let mut argv = self.prefix_args.clone(); + argv.extend(args.into_iter().map(Into::into)); + argv + } +} + +impl fmt::Display for PodRuntimeCommand { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.program.display())?; + for arg in &self.prefix_args { + write!(f, " {}", arg.to_string_lossy())?; + } + Ok(()) + } +} + +fn is_legacy_pod_binary(program: &Path) -> bool { + let Some(file_name) = program.file_name().and_then(OsStr::to_str) else { + return false; + }; + let stem = file_name.strip_suffix(".exe").unwrap_or(file_name); + stem == "insomnia-pod" +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvRestore(Option); + + impl EnvRestore { + fn capture() -> Self { + Self(std::env::var_os(POD_COMMAND_OVERRIDE_ENV)) + } + } + + impl Drop for EnvRestore { + fn drop(&mut self) { + unsafe { + match &self.0 { + Some(value) => std::env::set_var(POD_COMMAND_OVERRIDE_ENV, value), + None => std::env::remove_var(POD_COMMAND_OVERRIDE_ENV), + } + } + } + } + + #[test] + fn insomnia_binary_defaults_to_pod_prefix() { + let command = PodRuntimeCommand::for_executable("/opt/insomnia/bin/insomnia"); + + assert_eq!(command.program(), Path::new("/opt/insomnia/bin/insomnia")); + assert_eq!(command.prefix_args(), [OsString::from("pod")]); + assert_eq!( + command.argv_with(["--pod", "agent"]), + vec!["pod", "--pod", "agent"] + .into_iter() + .map(OsString::from) + .collect::>() + ); + } + + #[test] + fn legacy_wrapper_keeps_executable_only_command() { + let command = PodRuntimeCommand::for_executable("/opt/insomnia/bin/insomnia-pod"); + + assert_eq!( + command.program(), + Path::new("/opt/insomnia/bin/insomnia-pod") + ); + assert!(command.prefix_args().is_empty()); + assert_eq!( + command.argv_with(["--pod", "agent"]), + vec!["--pod", "agent"] + .into_iter() + .map(OsString::from) + .collect::>() + ); + } + + #[test] + fn env_override_is_executable_only_and_not_shell_parsed() { + let _guard = ENV_LOCK.lock().unwrap(); + let _restore = EnvRestore::capture(); + unsafe { + std::env::set_var(POD_COMMAND_OVERRIDE_ENV, "/tmp/mock pod --flag"); + } + + let command = PodRuntimeCommand::resolve().unwrap(); + + assert_eq!(command.program(), Path::new("/tmp/mock pod --flag")); + assert!(command.prefix_args().is_empty()); + } +} diff --git a/crates/pod/Cargo.toml b/crates/pod/Cargo.toml index 875c7077..d952557c 100644 --- a/crates/pod/Cargo.toml +++ b/crates/pod/Cargo.toml @@ -17,6 +17,7 @@ pod-store = { workspace = true } manifest = { workspace = true } protocol = { workspace = true } provider = { workspace = true } +pod-command = { workspace = true } pod-registry = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/crates/pod/src/discovery.rs b/crates/pod/src/discovery.rs index e8b617dc..fab0303b 100644 --- a/crates/pod/src/discovery.rs +++ b/crates/pod/src/discovery.rs @@ -16,6 +16,7 @@ use std::time::Duration; use async_trait::async_trait; use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use manifest::{Permission, ScopeRule}; +use pod_command::PodRuntimeCommand; use pod_store::{PodActiveSegmentRef, PodMetadata, PodMetadataStore}; use protocol::stream::JsonLineReader; use protocol::{Event, PodStatus}; @@ -328,8 +329,10 @@ where pod_name: &str, socket_path: &Path, ) -> Result<(), PodDiscoveryError> { - let mut command = Command::new(resolve_pod_command()); + let pod_command = PodRuntimeCommand::resolve().map_err(PodDiscoveryError::RestoreSpawn)?; + let mut command = Command::new(pod_command.program()); command + .args(pod_command.prefix_args()) .arg("--pod") .arg(pod_name) .arg("--require-pod-state") @@ -661,15 +664,6 @@ fn lookup_segment_lock( pod_registry::lookup_segment(segment_id) } -fn resolve_pod_command() -> PathBuf { - if let Ok(cmd) = std::env::var("INSOMNIA_POD_COMMAND") - && !cmd.is_empty() - { - return PathBuf::from(cmd); - } - PathBuf::from("insomnia-pod") -} - #[derive(Debug, Deserialize, JsonSchema)] struct PodNameInput { /// Pod name to restore. diff --git a/crates/pod/src/spawn/tool.rs b/crates/pod/src/spawn/tool.rs index 086bc1cd..f8f822cd 100644 --- a/crates/pod/src/spawn/tool.rs +++ b/crates/pod/src/spawn/tool.rs @@ -2,7 +2,7 @@ //! //! Wires pod-registry delegation, child manifest-config construction, subprocess //! launch, and socket handoff into a single `Tool` implementation. When -//! the LLM calls `SpawnPod`, a fresh `insomnia-pod` binary is exec'd in its own +//! the LLM calls `SpawnPod`, a fresh Pod runtime command is exec'd in its own //! process group, the pod-registry is updated atomically, and the child's //! first turn is kicked off by handing its socket a `Method::Run`. @@ -19,6 +19,7 @@ use manifest::{ ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig, ScopeRule, SessionConfigPartial, SharedScope, ToolOutputLimitsPartial, WorkerManifestConfig, }; +use pod_command::PodRuntimeCommand; use serde::Deserialize; use tokio::net::UnixStream; use tokio::process::Command; @@ -408,8 +409,9 @@ impl SpawnPodTool { spawn_config_json: &str, predicted_socket: &Path, ) -> Result<(), ToolError> { - let pod_command = - std::env::var("INSOMNIA_POD_COMMAND").unwrap_or_else(|_| "insomnia-pod".into()); + let pod_command = PodRuntimeCommand::resolve().map_err(|error| { + ToolError::ExecutionFailed(format!("failed to resolve Pod runtime command: {error}")) + })?; // Pre-create the child's runtime dir so we have a stable place to // capture its stderr before it has had a chance to bind anything. @@ -430,8 +432,9 @@ impl SpawnPodTool { ToolError::ExecutionFailed(format!("open {}: {e}", stderr_path.display())) })?; - let mut cmd = Command::new(&pod_command); - cmd.arg("--adopt") + let mut cmd = Command::new(pod_command.program()); + cmd.args(pod_command.prefix_args()) + .arg("--adopt") .arg("--callback") .arg(&self.callback_socket) .arg("--spawn-config-json") @@ -488,7 +491,7 @@ fn parse_scope(rules: &[ScopeRuleInput]) -> Result, ToolError> { } /// Serialise the internal manifest config that gets handed to the child -/// `insomnia-pod` binary via the hidden `--spawn-config-json` flag. +/// Pod runtime process via the hidden `--spawn-config-json` flag. /// `PodManifestConfig`'s `Serialize` impl is the single source of truth for the /// internal handoff shape. /// diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 0eda16a8..6e9da21d 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -65,7 +65,7 @@ enum Mode { profile: Option, }, /// `insomnia ` / `insomnia --pod `: attach to a live Pod by name if - /// possible; otherwise launch `insomnia-pod --pod ` so the pod process + /// possible; otherwise launch the Pod runtime command with `--pod ` so it /// resumes from name-keyed state or creates a fresh same-name Pod. PodName { pod_name: String, diff --git a/crates/tui/src/picker.rs b/crates/tui/src/picker.rs index 7e60242c..e1095a43 100644 --- a/crates/tui/src/picker.rs +++ b/crates/tui/src/picker.rs @@ -2,7 +2,7 @@ //! //! Reads live Pod allocations from the runtime registry and stopped Pod state //! from the pod-store name-keyed metadata. Picking a live row attaches to -//! its socket; picking a stopped row restores via `insomnia-pod --pod `. +//! its socket; picking a stopped row restores via the Pod runtime command. use std::io; use std::path::PathBuf; @@ -65,7 +65,7 @@ impl From for PickerError { pub enum PickerOutcome { /// User picked a Pod. `socket_override` is set for live rows when the /// runtime registry knows the exact socket path; stopped rows leave it - /// empty so the caller restores with `insomnia-pod --pod `. + /// empty so the caller restores by spawning the Pod runtime command. Picked { pod_name: String, socket_override: Option, diff --git a/crates/tui/src/spawn.rs b/crates/tui/src/spawn.rs index 4e0cc84e..d9c51b98 100644 --- a/crates/tui/src/spawn.rs +++ b/crates/tui/src/spawn.rs @@ -3,7 +3,7 @@ //! Rendered at the user's current cursor position when `insomnia` is invoked //! with no positional argument. Discovers `.insomnia/profiles.toml` profile //! choices plus bundled profiles, defaults to the builtin profile, prompts for -//! the Pod's name, and on confirmation launches the `insomnia-pod` binary as an +//! the Pod's name, and on confirmation launches the Pod runtime command as an //! independent process. Once the process reports its socket via the //! `INSOMNIA-READY` stderr line, the dialog hands control back so main can //! switch the terminal to alternate-screen mode. @@ -72,7 +72,7 @@ type InlineTerminal = Terminal>; /// Source session for a resume run. `None` = fresh spawn (current /// behaviour); `Some(id)` swaps the dialog into "Resume Pod" mode and -/// passes `--session ` to the spawned `insomnia-pod` child. +/// passes `--session ` to the spawned Pod runtime child. pub async fn run( resume_from: Option, profile: Option, @@ -162,7 +162,7 @@ pub async fn run( } } -/// Launch `insomnia-pod --pod ` without opening the name dialog. The child Pod +/// Launch a Pod runtime command with `--pod ` without opening the name dialog. The child Pod /// resolves persisted Pod metadata if present, or creates a fresh same-name Pod /// from the default profile. pub async fn run_pod_name(pod_name: String) -> Result { @@ -415,7 +415,7 @@ struct Form { /// When true, launch the child with `--pod ` so the pod process /// resolves name-keyed state before falling back to fresh creation. resume_by_pod_name: bool, - /// Optional profile choices passed to `insomnia-pod --profile` for + /// Optional profile choices passed with `--profile` for /// fresh spawns. This is not used for resume/attach flows because those must /// restore Pod state rather than re-evaluate a profile source. profile_choices: Vec,