From 625730cb0a219cc768e220eada159ad98b9cc26b Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 30 May 2026 03:54:56 +0900 Subject: [PATCH 1/2] feat: use builtin profile by default --- crates/manifest/src/profile.rs | 89 +++++++++++- crates/pod/src/main.rs | 214 ++++++++++++++++------------- crates/tui/src/spawn.rs | 195 ++++++-------------------- docs/manifest-profiles.md | 12 +- docs/manifest.toml | 20 +-- docs/nix.md | 4 +- docs/pod-factory.md | 24 ++-- resources/nix/profiles/default.nix | 40 ++++++ 8 files changed, 315 insertions(+), 283 deletions(-) create mode 100644 resources/nix/profiles/default.nix diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 6886031a..1bc3c820 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize}; use crate::{PodManifest, PodManifestConfig, ResolveError, paths}; const PROFILE_FORMAT_V1: &str = "insomnia.nix-profile.v1"; +const BUILTIN_DEFAULT_PROFILE_NAME: &str = "default"; /// Registry source for discovered profiles. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] @@ -221,6 +222,24 @@ impl ProfileRegistry { self.default = Some(default); } + fn set_builtin_default_if_available(&mut self) { + if self.default.is_some() { + return; + } + if self + .select_named( + Some(ProfileRegistrySource::Builtin), + BUILTIN_DEFAULT_PROFILE_NAME, + ) + .is_ok() + { + self.default = Some(ProfileDefault { + source: Some(ProfileRegistrySource::Builtin), + name: BUILTIN_DEFAULT_PROFILE_NAME.to_string(), + }); + } + } + fn mark_default_flags(&mut self) { let Some(default) = self.default.clone() else { return; @@ -282,6 +301,7 @@ impl ProfileDiscovery { if let Some(path) = &self.project_config { load_profile_registry_file(&mut registry, ProfileRegistrySource::Project, path)?; } + registry.set_builtin_default_if_available(); registry.mark_default_flags(); Ok(registry) } @@ -323,12 +343,14 @@ pub struct ResolvedProfile { #[derive(Debug, Clone)] pub struct NixProfileResolver { nix_bin: PathBuf, + workspace_base: Option, } impl Default for NixProfileResolver { fn default() -> Self { Self { nix_bin: PathBuf::from("nix"), + workspace_base: None, } } } @@ -341,9 +363,15 @@ impl NixProfileResolver { pub fn with_nix_bin(nix_bin: impl Into) -> Self { Self { nix_bin: nix_bin.into(), + workspace_base: None, } } + pub fn with_workspace_base(mut self, workspace_base: impl Into) -> Self { + self.workspace_base = Some(workspace_base.into()); + self + } + pub fn resolve(&self, selector: &ProfileSelector) -> Result { match selector { ProfileSelector::Path { path } => self.resolve_path( @@ -351,6 +379,7 @@ impl NixProfileResolver { ProfileSource::Path { path: absolutize(path)?, }, + None, ), ProfileSelector::Named { .. } | ProfileSelector::Default => { let cwd = std::env::current_dir().map_err(|source| ProfileError::CommandIo { @@ -359,6 +388,11 @@ impl NixProfileResolver { })?; let registry = ProfileDiscovery::for_cwd(&cwd).discover()?; let entry = registry.select(selector)?.clone(); + let artifact_base = if entry.source == ProfileRegistrySource::Builtin { + Some(absolutize(self.workspace_base.as_deref().unwrap_or(&cwd))?) + } else { + None + }; self.resolve_path( &entry.path, ProfileSource::Registry { @@ -366,6 +400,7 @@ impl NixProfileResolver { name: entry.name, path: absolutize(&entry.path)?, }, + artifact_base, ) } } @@ -375,15 +410,17 @@ impl NixProfileResolver { &self, path: &Path, source: ProfileSource, + manifest_base_override: Option, ) -> Result { let absolute_path = absolutize(path)?; - let base_dir = absolute_path + let file_base_dir = absolute_path .parent() .map(Path::to_path_buf) .ok_or_else(|| ProfileError::InvalidPath { path: absolute_path.clone(), message: "profile path has no parent directory".to_string(), })?; + let base_dir = manifest_base_override.unwrap_or(file_base_dir); let output = Command::new(&self.nix_bin) .arg("eval") @@ -948,6 +985,56 @@ mod tests { ); } + #[test] + fn builtin_default_profile_is_registered_as_default() { + let registry = ProfileDiscovery::with_sources(paths::builtin_profiles_dir(), None, None) + .discover() + .unwrap(); + let default = registry.default_entry().unwrap(); + assert_eq!(default.source, ProfileRegistrySource::Builtin); + assert_eq!(default.name, BUILTIN_DEFAULT_PROFILE_NAME); + assert!(default.is_default); + assert!(default.path.ends_with("resources/nix/profiles/default.nix")); + } + + #[cfg(unix)] + #[test] + fn builtin_profile_relative_paths_resolve_against_workspace_base() { + use std::os::unix::fs::PermissionsExt; + + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + let profile_path = tmp.path().join("default.nix"); + std::fs::write(&profile_path, "{}").unwrap(); + let nix_bin = tmp.path().join("fake-nix"); + std::fs::write( + &nix_bin, + r#"#!/bin/sh +cat <<'JSON' +{"profile":{"format":"insomnia.nix-profile.v1","name":"default"},"manifest":{"pod":{"name":"default"},"model":{"scheme":"anthropic","model_id":"claude-sonnet-4-20250514"},"scope":{"allow":[{"target":".","permission":"write"}]}}} +JSON +"#, + ) + .unwrap(); + let mut perms = std::fs::metadata(&nix_bin).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&nix_bin, perms).unwrap(); + let resolved = NixProfileResolver::with_nix_bin(&nix_bin) + .resolve_path( + &profile_path, + ProfileSource::Registry { + source: ProfileRegistrySource::Builtin, + name: BUILTIN_DEFAULT_PROFILE_NAME.to_string(), + path: profile_path.clone(), + }, + Some(workspace.clone()), + ) + .unwrap(); + + assert_eq!(resolved.manifest.scope.allow[0].target, workspace); + } + #[test] fn for_cwd_reads_profiles_toml_and_ignores_manifest_profiles() { let tmp = TempDir::new().unwrap(); diff --git a/crates/pod/src/main.rs b/crates/pod/src/main.rs index e1d581ae..b277c50d 100644 --- a/crates/pod/src/main.rs +++ b/crates/pod/src/main.rs @@ -4,13 +4,13 @@ use std::process::ExitCode; use clap::Parser; use manifest::{NixProfileResolver, PodManifest, PodManifestConfig, ProfileSelector, paths}; -use pod::{Pod, PodController, PodFactory, PromptLoader}; +use pod::{Pod, PodController, PromptLoader}; use session_store::{FsStore, PodMetadataStore, SegmentId, Store}; #[derive(Debug, Parser)] #[command( name = "insomnia-pod", - about = "Spawn a Pod process from manifest layers or a single manifest file" + about = "Spawn a Pod process from a Nix profile or a single manifest file" )] struct Cli { /// Nix profile to evaluate. Accepts an explicit path, `path:`, a @@ -29,19 +29,20 @@ struct Cli { #[arg(long, value_name = "NAME", requires = "profile", conflicts_with_all = ["pod", "session", "adopt"])] profile_pod_name: Option, - /// Manifest TOML to use directly, without loading user, project, or - /// overlay layers. + /// Manifest TOML to use directly as a one-file compatibility/debug input. + /// This bypasses profile discovery but still applies builtin defaults and + /// the same required-field validation boundary. #[arg(long, value_name = "PATH", conflicts_with_all = ["project", "overlay"])] manifest: Option, - /// Start the project-manifest walk from this directory. When - /// omitted, the factory walks up from the current working - /// directory looking for `.insomnia/manifest.toml`. + /// Deprecated manifest-cascade project root flag. Ambient project/user + /// manifest discovery has been removed; configure/select a profile instead. #[arg(long, value_name = "PATH")] project: Option, - /// Inline TOML string applied as the highest-priority overlay - /// layer. Example: `--overlay 'pod.name = "dbg"'`. + /// Inline TOML override applied to the implicit default profile. This is + /// retained for launchers that need to supply restore/session-time values; + /// it does not re-enable user/project manifest discovery. #[arg(long, value_name = "TOML")] overlay: Option, @@ -94,7 +95,7 @@ fn resolve_manifest_with_user_manifest_env( fn resolve_manifest_with_user_manifest_env_and_profile_loader( cli: &Cli, - user_manifest_env: Option, + _user_manifest_env: Option, load_profile_fn: F, ) -> Result<(PodManifest, PromptLoader), String> where @@ -105,29 +106,31 @@ where return load_profile_fn(&selector, cli.profile_pod_name.as_deref()); } - let user_manifest = paths::user_manifest_path_from_env(user_manifest_env); - if let Some(path) = &cli.manifest { - if user_manifest.is_some() { - return Err(format!( - "--manifest cannot be used when {} is set", - paths::USER_MANIFEST_ENV - )); - } return load_single_manifest(path, cli.pod.as_deref()); } - let factory = build_factory_with_user_manifest_path(cli, user_manifest)?; - factory - .resolve() - .map_err(|e| format!("failed to resolve manifest cascade: {e}")) + if cli.project.is_some() { + return Err( + "--project is no longer supported; normal startup uses profile discovery/default, \ + and --manifest is the only one-file manifest mode" + .to_string(), + ); + } + + let selector = ProfileSelector::Default; + let (manifest, loader) = load_profile_fn(&selector, None)?; + let manifest = apply_cli_overrides_to_manifest(manifest, cli)?; + Ok((manifest, loader)) } fn load_profile( selector: &ProfileSelector, pod_name_override: Option<&str>, ) -> Result<(PodManifest, PromptLoader), String> { - let resolver = NixProfileResolver::new(); + let cwd = std::env::current_dir() + .map_err(|e| format!("failed to resolve current directory for profile: {e}"))?; + let resolver = NixProfileResolver::new().with_workspace_base(cwd); let mut resolved = resolver.resolve(selector).map_err(|e| { format!( "failed to resolve profile {}: {e}", @@ -146,28 +149,32 @@ fn load_single_manifest( ) -> Result<(PodManifest, PromptLoader), String> { let toml = std::fs::read_to_string(path) .map_err(|e| format!("failed to read manifest {}: {e}", path.display()))?; - let manifest = match pod_name_override { - Some(pod_name) => match PodManifest::from_toml(&toml) { - Ok(mut manifest) => { - manifest.pod.name = pod_name.to_string(); - manifest - } - Err(_) => { - let base = PodManifestConfig::from_toml(&toml) - .map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?; - let overlay = PodManifestConfig::from_toml(&pod_name_overlay_toml(pod_name)) - .expect("pod name overlay TOML is generated"); - PodManifest::try_from(base.merge(overlay)).map_err(|e| { - format!( - "failed to resolve manifest {} with --pod: {e}", - path.display() - ) - })? - } - }, - None => PodManifest::from_toml(&toml) - .map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?, + let absolute_path = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|e| format!("failed to resolve current directory: {e}"))? + .join(path) }; + let base_dir = absolute_path.parent().ok_or_else(|| { + format!( + "manifest path {} has no parent directory", + absolute_path.display() + ) + })?; + let mut config = PodManifestConfig::builtin_defaults().merge( + PodManifestConfig::from_toml(&toml) + .map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))? + .resolve_paths(base_dir), + ); + if let Some(pod_name) = pod_name_override { + config = config.merge( + PodManifestConfig::from_toml(&pod_name_overlay_toml(pod_name)) + .expect("pod name overlay TOML is generated"), + ); + } + let manifest = PodManifest::try_from(config) + .map_err(|e| format!("failed to resolve manifest {}: {e}", path.display()))?; Ok((manifest, PromptLoader::builtins_only())) } @@ -179,43 +186,33 @@ fn pod_name_overlay_toml(pod_name: &str) -> String { toml::to_string(&toml::Value::Table(root)).expect("pod name overlay serialisation cannot fail") } -fn build_factory_with_user_manifest_path( +fn manifest_to_config(manifest: &PodManifest) -> Result { + let value = serde_json::to_value(manifest) + .map_err(|e| format!("failed to serialise resolved manifest for overlay: {e}"))?; + serde_json::from_value(value) + .map_err(|e| format!("failed to convert resolved manifest for overlay: {e}")) +} + +fn apply_cli_overrides_to_manifest( + mut manifest: PodManifest, cli: &Cli, - user_manifest: Option, -) -> Result { - let mut factory = PodFactory::new(); - - factory = match user_manifest { - Some(path) => factory - .with_user_manifest(path) - .map_err(|e| format!("failed to load user manifest: {e}"))?, - None => factory - .with_user_manifest_auto() - .map_err(|e| format!("failed to auto-load user manifest: {e}"))?, - }; - - factory = match &cli.project { - Some(path) => factory - .with_project_manifest_from(path) - .map_err(|e| format!("failed to load project manifest: {e}"))?, - None => factory - .with_project_manifest_auto() - .map_err(|e| format!("failed to auto-load project manifest: {e}"))?, - }; - +) -> Result { + let profile = manifest.profile.clone(); if let Some(overlay) = cli.overlay.as_deref() { - factory = factory - .with_overlay_toml(overlay) - .map_err(|e| format!("failed to parse overlay TOML: {e}"))?; + let base_dir = std::env::current_dir() + .map_err(|e| format!("failed to resolve current directory for overlay: {e}"))?; + let overlay = PodManifestConfig::from_toml(overlay) + .map_err(|e| format!("failed to parse overlay TOML: {e}"))? + .resolve_paths(&base_dir); + let config = manifest_to_config(&manifest)?.merge(overlay); + manifest = PodManifest::try_from(config) + .map_err(|e| format!("failed to resolve default profile overlay: {e}"))?; + manifest.profile = profile; } - if let Some(pod_name) = cli.pod.as_deref() { - factory = factory - .with_overlay_toml(&pod_name_overlay_toml(pod_name)) - .map_err(|e| format!("failed to apply --pod overlay: {e}"))?; + manifest.pod.name = pod_name.to_string(); } - - Ok(factory) + Ok(manifest) } #[tokio::main] @@ -442,18 +439,20 @@ permission = "write" } #[test] - fn manifest_conflicts_with_user_manifest_env_when_env_is_non_empty() { + fn manifest_ignores_non_empty_user_manifest_env() { let tmp = TempDir::new().unwrap(); let manifest = tmp.path().join("manifest.toml"); write(&manifest, &manifest_toml("single", tmp.path())); let cli = Cli::try_parse_from(["insomnia-pod", "--manifest", manifest.to_str().unwrap()]) .unwrap(); - let err = resolve_manifest_with_user_manifest_env(&cli, Some(OsString::from("user.toml"))) - .unwrap_err(); + let (manifest, loader) = + resolve_manifest_with_user_manifest_env(&cli, Some(OsString::from("user.toml"))) + .unwrap(); - assert!(err.contains("--manifest cannot be used")); - assert!(err.contains(paths::USER_MANIFEST_ENV)); + assert_eq!(manifest.pod.name, "single"); + assert!(loader.user_dir().is_none()); + assert!(loader.workspace_dir().is_none()); } #[test] @@ -549,26 +548,38 @@ permission = "write" } #[test] - fn user_manifest_env_overrides_auto_user_manifest_path() { + fn normal_startup_uses_default_profile_and_ignores_user_manifest_env() { let tmp = TempDir::new().unwrap(); - let user_manifest = tmp.path().join("custom-user.toml"); - write(&user_manifest, &manifest_toml("from-env", tmp.path())); - let no_project_root = tmp.path().join("no-project"); - std::fs::create_dir_all(&no_project_root).unwrap(); - let cli = Cli::try_parse_from([ - "insomnia-pod", - "--project", - no_project_root.to_str().unwrap(), - ]) - .unwrap(); + let cli = Cli::try_parse_from(["insomnia-pod"]).unwrap(); + let mut called = false; - let (manifest, _loader) = resolve_manifest_with_user_manifest_env( + let (manifest, _loader) = resolve_manifest_with_user_manifest_env_and_profile_loader( &cli, - Some(user_manifest.as_os_str().to_os_string()), + Some(OsString::from("ignored-user-manifest.toml")), + |selector, pod_name| { + called = true; + assert_eq!(selector, &ProfileSelector::Default); + assert_eq!(pod_name, None); + let manifest = + PodManifest::from_toml(&manifest_toml("from-default-profile", tmp.path())) + .unwrap(); + Ok((manifest, PromptLoader::builtins_only())) + }, ) .unwrap(); - assert_eq!(manifest.pod.name, "from-env"); + assert!(called); + assert_eq!(manifest.pod.name, "from-default-profile"); + } + + #[test] + fn project_flag_no_longer_enables_ambient_manifest_cascade() { + let cli = Cli::try_parse_from(["insomnia-pod", "--project", "."]).unwrap(); + let err = resolve_manifest_with_user_manifest_env_and_profile_loader(&cli, None, |_, _| { + panic!("default profile loader must not run when deprecated --project is present") + }) + .unwrap_err(); + assert!(err.contains("--project is no longer supported")); } #[test] @@ -605,7 +616,17 @@ permission = "write" let manifest = tmp.path().join("manifest.toml"); write( &manifest, - &manifest_toml("unused", tmp.path()).replace("name = \"unused\"\n", ""), + r#" +[pod] + +[model] +scheme = "anthropic" +model_id = "test-model" + +[[scope.allow]] +target = "." +permission = "write" +"#, ); let cli = Cli::try_parse_from([ "insomnia-pod", @@ -619,6 +640,7 @@ permission = "write" let (manifest, _loader) = resolve_manifest_with_user_manifest_env(&cli, None).unwrap(); assert_eq!(manifest.pod.name, "from-flag"); + assert_eq!(manifest.scope.allow[0].target, tmp.path()); } #[test] diff --git a/crates/tui/src/spawn.rs b/crates/tui/src/spawn.rs index 9d70df26..199bc0bf 100644 --- a/crates/tui/src/spawn.rs +++ b/crates/tui/src/spawn.rs @@ -1,29 +1,23 @@ //! Inline-viewport "spawn Pod and attach" UX. //! //! Rendered at the user's current cursor position when `insomnia` is invoked -//! with no positional argument. Walks the cwd for a `.insomnia/manifest.toml` -//! to seed manifest defaults and `.insomnia/profiles.toml` to discover profile -//! choices, prompts for the Pod's name, and on confirmation -//! launches the `insomnia-pod` binary as an independent process with a freshly built -//! overlay (name + cwd scope when no project manifest exists). 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. +//! 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 +//! 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. //! //! The viewport's last frame stays in the terminal's scrollback so the //! user has a record of what was spawned (or why a spawn failed). -use std::ffi::OsString; use std::io; use std::path::{Path, PathBuf}; use std::time::Duration; use client::{SpawnConfig, spawn_pod}; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; -use manifest::{ - PodManifestConfig, ProfileDiscovery, ScopeConfig, find_project_manifest_from, load_layer, - user_manifest_path, user_manifest_path_from_env, -}; +use manifest::{ProfileDiscovery, ScopeConfig}; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Layout}; @@ -97,7 +91,11 @@ pub async fn run( profile: Option, ) -> Result { let defaults = load_spawn_defaults()?; - let mut profile_choices = defaults.profile_choices; + let mut profile_choices = if resume_from.is_some() { + Vec::new() + } else { + defaults.profile_choices + }; let profile_index = initial_profile_index( &mut profile_choices, profile.as_deref(), @@ -232,42 +230,6 @@ struct ProfileChoice { fn load_spawn_defaults() -> Result { let cwd = std::env::current_dir().map_err(SpawnError::Io)?; - // Run the same merge pod itself uses, then read what's missing off the - // result. We only look at `scope.allow` here — `pod.name` is an - // instance-level identifier and is supplied by the dialog or `--pod`. - // TUI must pre-read the same user manifest path that the pod CLI will use, - // including a non-empty INSOMNIA_USER_MANIFEST override; empty values fall - // back to the auto-discovered path. - let user_layer = user_manifest_path_for_spawn( - std::env::var_os(manifest::paths::USER_MANIFEST_ENV), - user_manifest_path(), - ) - .filter(|p| p.is_file()) - .and_then(|p| load_layer(&p).ok()); - let project_layer = find_project_manifest_from(&cwd).and_then(|p| load_layer(&p).ok()); - - let mut cascade = PodManifestConfig::builtin_defaults(); - for layer in [user_layer.as_ref(), project_layer.as_ref()] - .into_iter() - .flatten() - { - cascade = cascade.merge(layer.clone()); - } - let cascade_has_scope = !cascade.scope.allow.is_empty(); - - let scope_origin = match ( - project_layer - .as_ref() - .is_some_and(|l| !l.scope.allow.is_empty()), - user_layer - .as_ref() - .is_some_and(|l| !l.scope.allow.is_empty()), - ) { - (true, _) => ScopeOrigin::FromProject, - (false, true) => ScopeOrigin::FromUser, - (false, false) => ScopeOrigin::CwdDefault, - }; - let default_name = cwd .file_name() .and_then(|s| s.to_str()) @@ -279,8 +241,8 @@ fn load_spawn_defaults() -> Result { Ok(SpawnDefaults { cwd, - cascade_has_scope, - scope_origin, + cascade_has_scope: true, + scope_origin: ScopeOrigin::FromProfile, default_name, default_profile_index, profile_choices, @@ -288,16 +250,11 @@ fn load_spawn_defaults() -> Result { } fn profile_choices_for_cwd(cwd: &Path) -> (Vec, usize) { - let mut choices = vec![ProfileChoice { - selector: None, - label: "manifest cascade".to_string(), - is_default: false, - }]; - let Ok(registry) = ProfileDiscovery::for_cwd(cwd).discover() else { - return (choices, 0); + return (Vec::new(), 0); }; + let mut choices = Vec::new(); for entry in registry.entries() { let mut label = entry.qualified_name(); if entry.is_default { @@ -343,13 +300,6 @@ fn initial_profile_index( choices.len() - 1 } -fn user_manifest_path_for_spawn( - env_value: Option, - default_user_manifest: Option, -) -> Option { - user_manifest_path_from_env(env_value).or(default_user_manifest) -} - fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form { Form { cwd: defaults.cwd, @@ -362,11 +312,7 @@ fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form { resume_from: None, resume_by_pod_name: true, resume_scope: None, - profile_choices: vec![ProfileChoice { - selector: None, - label: "manifest cascade".to_string(), - is_default: false, - }], + profile_choices: Vec::new(), profile_index: 0, } } @@ -519,16 +465,14 @@ enum MessageKind { } enum ScopeOrigin { - FromUser, - FromProject, + FromProfile, CwdDefault, } struct Form { cwd: PathBuf, - /// True when at least one cascade layer (user or project manifest) - /// already declares `scope.allow`. Drives whether the overlay - /// should add a cwd-write rule. + /// True when the launch source already supplies `scope.allow`. + /// Drives whether the compatibility overlay should add a cwd-write rule. cascade_has_scope: bool, /// Display label for the scope row in the dialog. scope_origin: ScopeOrigin, @@ -648,7 +592,7 @@ fn draw_form(f: &mut Frame<'_>, form: &Form) { let layout = Layout::vertical([ Constraint::Length(1), // title Constraint::Length(1), // name field - Constraint::Length(1), // context (manifest or scope default) + Constraint::Length(1), // context (profile or scope default) Constraint::Length(1), // hint Constraint::Length(1), // message Constraint::Length(1), // spacer @@ -716,15 +660,10 @@ fn context_line(form: &Form) -> Line<'_> { } match form.scope_origin { - ScopeOrigin::FromProject => Line::from(vec![ + ScopeOrigin::FromProfile => Line::from(vec![ Span::raw(" "), Span::styled("scope: ", Style::default().fg(Color::DarkGray)), - Span::styled("from project manifest", Style::default().fg(Color::Green)), - ]), - ScopeOrigin::FromUser => Line::from(vec![ - Span::raw(" "), - Span::styled("scope: ", Style::default().fg(Color::DarkGray)), - Span::styled("from user manifest", Style::default().fg(Color::Green)), + Span::styled("from default profile", Style::default().fg(Color::Green)), ]), ScopeOrigin::CwdDefault => Line::from(vec![ Span::raw(" "), @@ -767,7 +706,7 @@ mod tests { cwd: PathBuf::from("/work/example"), cascade_has_scope, scope_origin: if cascade_has_scope { - ScopeOrigin::FromProject + ScopeOrigin::FromProfile } else { ScopeOrigin::CwdDefault }, @@ -778,11 +717,7 @@ mod tests { resume_from: None, resume_by_pod_name: false, resume_scope: None, - profile_choices: vec![ProfileChoice { - selector: None, - label: "manifest cascade".to_string(), - is_default: false, - }], + profile_choices: Vec::new(), profile_index: 0, } } @@ -792,14 +727,10 @@ mod tests { let defaults = SpawnDefaults { cwd: PathBuf::from("/work/example"), cascade_has_scope: true, - scope_origin: ScopeOrigin::FromProject, + scope_origin: ScopeOrigin::FromProfile, default_name: "ignored".to_string(), default_profile_index: 0, - profile_choices: vec![ProfileChoice { - selector: None, - label: "manifest cascade".to_string(), - is_default: false, - }], + profile_choices: Vec::new(), }; let f = form_for_pod_name("agent".to_string(), defaults); @@ -860,46 +791,6 @@ mod tests { assert_eq!(deny[0]["target"].as_str(), Some("/work/example/child")); } - #[test] - fn cascade_merge_detects_scope_from_any_layer() { - let user = PodManifestConfig::from_toml( - r#" -[[scope.allow]] -target = "/from-user" -permission = "write" -"#, - ) - .unwrap(); - let mut cascade = PodManifestConfig::builtin_defaults(); - cascade = cascade.merge(user); - assert!(!cascade.scope.allow.is_empty()); - - let empty_cascade = PodManifestConfig::builtin_defaults(); - assert!(empty_cascade.scope.allow.is_empty()); - } - - #[test] - fn user_manifest_path_for_spawn_prefers_non_empty_env_override() { - assert_eq!( - user_manifest_path_for_spawn( - Some(OsString::from("/tmp/override.toml")), - Some(PathBuf::from("/default/manifest.toml")), - ), - Some(PathBuf::from("/tmp/override.toml")), - ); - } - - #[test] - fn user_manifest_path_for_spawn_treats_empty_env_as_unset() { - assert_eq!( - user_manifest_path_for_spawn( - Some(OsString::from("")), - Some(PathBuf::from("/default/manifest.toml")), - ), - Some(PathBuf::from("/default/manifest.toml")), - ); - } - #[test] fn profile_choices_use_project_registry_default() { let temp = tempfile::tempdir().unwrap(); @@ -925,7 +816,7 @@ coder = "profiles/coder.nix" } #[test] - fn profile_choices_include_no_profile_source_labels_and_default_marker() { + fn profile_choices_include_builtin_and_project_default_marker() { let temp = tempfile::tempdir().unwrap(); let project = temp.path().join("project"); let insomnia = project.join(".insomnia"); @@ -942,22 +833,17 @@ description = "Project coder" .unwrap(); let (choices, default_index) = profile_choices_for_cwd(&project); - assert_eq!(choices[0].selector, None); - assert_eq!(choices[0].label, "manifest cascade"); + assert_eq!(choices[0].selector.as_deref(), Some("builtin:default")); + assert_eq!(choices[0].label, "builtin:default"); assert_eq!(default_index, 1); assert_eq!(choices[1].selector.as_deref(), Some("project:coder")); assert_eq!(choices[1].label, "project:coder (default) — Project coder"); } #[test] - fn profile_cycle_selects_profiles_and_can_opt_out_of_default() { + fn profile_cycle_selects_profiles_without_manifest_cascade_opt_out() { let mut form = form("coder", true); form.profile_choices = vec![ - ProfileChoice { - selector: None, - label: "manifest cascade".to_string(), - is_default: false, - }, ProfileChoice { selector: Some("project:coder".to_string()), label: "project:coder (default)".to_string(), @@ -969,7 +855,7 @@ description = "Project coder" is_default: false, }, ]; - form.profile_index = 1; + form.profile_index = 0; assert_eq!( form.selected_profile_selector().as_deref(), @@ -981,7 +867,10 @@ description = "Project coder" Some("user:reviewer") ); form.cycle_profile_next(); - assert_eq!(form.selected_profile_selector(), None); + assert_eq!( + form.selected_profile_selector().as_deref(), + Some("project:coder") + ); form.cycle_profile_prev(); assert_eq!( form.selected_profile_selector().as_deref(), @@ -991,15 +880,11 @@ description = "Project coder" #[test] fn initial_profile_index_adds_explicit_selector_not_in_discovery_list() { - let mut choices = vec![ProfileChoice { - selector: None, - label: "manifest cascade".to_string(), - is_default: false, - }]; + let mut choices = Vec::new(); let selected = initial_profile_index(&mut choices, Some("coder"), 0); - assert_eq!(selected, 1); - assert_eq!(choices[1].selector.as_deref(), Some("coder")); - assert_eq!(choices[1].label, "coder"); + assert_eq!(selected, 0); + assert_eq!(choices[0].selector.as_deref(), Some("coder")); + assert_eq!(choices[0].label, "coder"); } #[test] diff --git a/docs/manifest-profiles.md b/docs/manifest-profiles.md index 74bbc692..20405b33 100644 --- a/docs/manifest-profiles.md +++ b/docs/manifest-profiles.md @@ -61,7 +61,7 @@ path = "profiles/coder.nix" description = "Project coding assistant" ``` -Relative registry paths are resolved against the `profiles.toml` file that declares them. Discovery checks bundled builtin profiles, then the user registry at `/profiles.toml`, then the nearest project registry at `.insomnia/profiles.toml`. Later defaults override earlier defaults, so a project default wins over a user default. Unqualified defaults resolve within the declaring source by default. Unqualified ambiguous names fail closed: +Relative registry paths are resolved against the `profiles.toml` file that declares them. Discovery checks bundled builtin profiles, then the user registry at `/profiles.toml`, then the nearest project registry at `.insomnia/profiles.toml`. The bundled `builtin:default` profile is the fallback default when no user/project registry declares another default. Later defaults override earlier defaults, so a project default wins over a user default, and either wins over the builtin default. Unqualified defaults resolve within the declaring source by default. Unqualified ambiguous names fail closed: ```sh insomnia --profile coder # fails if both user:coder and project:coder exist @@ -69,7 +69,13 @@ insomnia --profile project:coder # source-qualified selection insomnia --profile default # selected registry default ``` -The fresh-spawn TUI also uses discovery. If a default profile is configured, the new Pod dialog shows a selectable source-qualified profile row such as `profile: project:coder (default)`. `Tab`/`Down` cycles forward through discovered profiles, `Shift-Tab`/`Up` cycles backward, and the `manifest cascade` choice opts out of a default profile for that spawn. Passing `insomnia --profile ` opens the same new Pod dialog with that selector selected and leaves Pod-name editing unchanged. +The fresh-spawn TUI also uses discovery. The new Pod dialog defaults to the selected registry default, normally `builtin:default` unless a user/project registry overrides it. `Tab`/`Down` cycles forward through discovered profiles and `Shift-Tab`/`Up` cycles backward; there is no ambient manifest-cascade opt-out. Passing `insomnia --profile ` opens the same new Pod dialog with that selector selected and leaves Pod-name editing unchanged. + +## One-file manifests + +`insomnia-pod --manifest ` remains as an explicit compatibility/debug path. It reads exactly that TOML file, resolves relative paths against the file's parent directory, merges builtin defaults, and validates through the same `PodManifestConfig -> PodManifest` boundary as profile artifacts. It does not load user or project `manifest.toml` files and conflicts with `--profile`. + +Ambient user/project `manifest.toml` cascade startup has been removed. Normal fresh spawns use profile discovery/default selection, with `profiles.toml` acting only as a profile registry/default selector. ## Artifact contract @@ -79,7 +85,7 @@ A profile should evaluate to one of: - `{ profile = { format = "insomnia.nix-profile.v1"; ... }; config = { ... }; }` - a raw manifest/config object for debug/test paths. -The `manifest`/`config` object uses the same field names as the existing manifest config. Relative paths are resolved against the directory containing the profile file, then builtin defaults are merged and validation produces the runtime `PodManifest`. +The resolved artifact is deserialized into the same `PodManifestConfig -> PodManifest` boundary used by direct one-file manifests, so builtin defaults and required-field validation stay shared. Explicit profile paths and user/project registry profile artifacts resolve relative manifest paths against the profile file's directory. Builtin profile artifacts resolve manifest-relative paths against the launch workspace/current directory so the bundled default can grant `scope.allow target = "."` for the workspace rather than for `resources/nix/profiles`. Secret values must stay as typed references. `resources/nix/profile-lib.nix` emits secret references as JSON like: diff --git a/docs/manifest.toml b/docs/manifest.toml index 30e0a01e..716adbf6 100644 --- a/docs/manifest.toml +++ b/docs/manifest.toml @@ -3,17 +3,17 @@ # ============================================================================ # Pod の宣言的設定 (`PodManifest` / `PodManifestConfig`)。 # -# カスケード層は下から順に -# 1. builtin defaults (`manifest::defaults`) -# 2. user manifest (`/manifest.toml`) -# 3. project manifest (cwd から上方向に探す `.insomnia/manifest.toml`) -# 4. programmatic overlay (呼び出し側が差し込む) -# 上の層が同名フィールドを上書き、scope rule と skills.directories は -# 累積マージ、tool_output.per_tool は key 単位でマージ。 +# このファイル形式は低レベル runtime manifest。通常起動は profile discovery/default +# (`profiles.toml` と bundled builtin profile) から manifest を生成する。 +# `insomnia-pod --manifest ` の one-file compatibility/debug mode では、 +# 指定した TOML 1 枚に builtin defaults を merge し、required validation を行う。 +# user/project `manifest.toml` を暗黙に merge する通常起動 cascade は使わない。 # -# パス解決: 相対パスは「その層の manifest ファイルが置かれているディレクトリ」 -# を base に絶対パスへ解決される (overlay 層は cwd)。マージは絶対化済みの -# 値同士で行われる。 +# `PodManifestConfig` の merge 規則: 上の層が同名フィールドを上書き、scope rule と +# skills.directories は累積マージ、tool_output.per_tool は key 単位でマージ。 +# +# パス解決: `--manifest ` では相対パスはその manifest ファイルの親ディレクトリ +# を base に絶対パスへ解決される。profile artifact でも同じ validation 境界を通る。 # # 凡例: # - 必須 … 値が無いと resolve エラー diff --git a/docs/nix.md b/docs/nix.md index e167ab81..4c491ff4 100644 --- a/docs/nix.md +++ b/docs/nix.md @@ -43,11 +43,11 @@ The Nix package does not put user configuration, sessions, sockets, or other mut | Purpose | Override | `INSOMNIA_HOME` fallback | XDG / default fallback | | --- | --- | --- | --- | -| User config (`manifest.toml`, prompt overrides, model/provider overrides) | `INSOMNIA_CONFIG_DIR` | `$INSOMNIA_HOME/config` | `$XDG_CONFIG_HOME/insomnia`, then `$HOME/.config/insomnia` | +| User config (`profiles.toml`, prompt overrides, model/provider overrides) | `INSOMNIA_CONFIG_DIR` | `$INSOMNIA_HOME/config` | `$XDG_CONFIG_HOME/insomnia`, then `$HOME/.config/insomnia` | | Persistent data (`sessions/`, Pod metadata) | `INSOMNIA_DATA_DIR` | `$INSOMNIA_HOME` | `$HOME/.insomnia` | | Runtime state (sockets, lock files, live registry) | `INSOMNIA_RUNTIME_DIR` | `$INSOMNIA_HOME/run` | `$XDG_RUNTIME_DIR/insomnia`, then `$HOME/.insomnia/run` | -`INSOMNIA_USER_MANIFEST=` can still be used to select an explicit user manifest for the Pod CLI cascade path. Project manifests are still discovered from `.insomnia/manifest.toml` under the current workspace unless a CLI mode documents otherwise. +Normal fresh startup is profile-based. The package ships a builtin default profile, user/project `profiles.toml` files may select or define profiles, and `insomnia-pod --manifest ` remains a one-file compatibility/debug input. `INSOMNIA_USER_MANIFEST` and ambient `.insomnia/manifest.toml` discovery are not part of normal Pod/TUI startup. ## Validation diff --git a/docs/pod-factory.md b/docs/pod-factory.md index 7fda3282..09da786c 100644 --- a/docs/pod-factory.md +++ b/docs/pod-factory.md @@ -337,42 +337,34 @@ import-map 形式のプレフィックスで指定する: ## `insomnia-pod` CLI -`insomnia-pod` は通常、builtin default → user manifest → project manifest → overlay の cascade で manifest を解決して起動する。 +`insomnia-pod` の通常起動は profile discovery/default から runtime manifest を作る。user/project `manifest.toml` の ambient cascade は通常起動では使わない。 ``` -insomnia-pod [--project ] [--overlay ] [-s/--store ] [--session ] +insomnia-pod [--profile ] [--profile-pod-name ] [-s/--store ] [--session ] ``` | フラグ | 説明 | |---|---| -| `--project ` | プロジェクト manifest 探索の起点。省略時は cwd から上方向に `.insomnia/manifest.toml` を探索 | -| `--overlay ` | 最上層の overlay を inline TOML 文字列で渡す(例: `--overlay 'worker.instruction = "$user/foo"'`) | +| `--profile ` | builtin/user/project profile registry から Nix profile を選択。省略時は registry default(通常は `builtin:default`) | +| `--profile-pod-name ` | profile 由来 manifest の `pod.name` を fresh spawn 用に上書き | +| `--overlay ` | TUI/launcher compatibility 用の inline override。user/project manifest discovery は行わない | | `-s, --store ` | セッション永続化ディレクトリ(デフォルト: `/sessions/`、`manifest::paths` で解決) | | `--session ` | 既存 session id から Pod を復元し、同じ jsonl に後続 turn を追記する | -user manifest は CLI フラグではなく、以下の規則で解決する。 - -| 入力 | 挙動 | -|---|---| -| `INSOMNIA_USER_MANIFEST=` | 指定 path を user manifest として読む。ファイル不在や parse error は起動エラー | -| `INSOMNIA_USER_MANIFEST=` | 空文字列は未指定扱い | -| env 未指定 | `manifest::paths::user_manifest_path()` で自動探索し、存在すれば読む | - -単一ファイルだけで起動したい場合は cascade を使わず、`--manifest` を指定する。 +単一ファイルだけで起動したい場合は `--manifest` を指定する。 ``` insomnia-pod --manifest [-s/--store ] [--session ] ``` -`--manifest` は指定 TOML 1 枚だけを `PodManifest::from_toml` で読み、user / project / overlay layer は一切読まない。したがって `--project`、`--overlay`、非空の `INSOMNIA_USER_MANIFEST` とは併用不可。 +`--manifest` は指定 TOML 1 枚だけを読み、builtin defaults を merge したうえで `PodManifestConfig -> PodManifest` の required validation を通す。user / project manifest layer は読まない。`--profile`、`--project`、`--overlay` とは併用不可。 spawn 子 Pod 用の内部フラグとして `--adopt` と `--callback ` がある。これらは `SpawnPod` が scope allocation と親 callback socket を引き継がせるために使うもので、通常の手動起動では使わない。 Pod の作業ディレクトリは `insomnia-pod` 起動時の cwd が直接使われる。別ディレクトリで 動かしたい場合は `cd && insomnia-pod ...` のように外側で `cd` してから起動する。 -引数無しで起動すると、cwd + `manifest::paths` の自動解決だけで動く最小構成になる -(overlay 無し、プロジェクトに `.insomnia/manifest.toml` があればそれを使う)。 +引数無しで起動すると、profile registry default(通常は bundled `builtin:default`)で起動する。 --- diff --git a/resources/nix/profiles/default.nix b/resources/nix/profiles/default.nix new file mode 100644 index 00000000..6c5bcd0a --- /dev/null +++ b/resources/nix/profiles/default.nix @@ -0,0 +1,40 @@ +let + insomnia = import ../profile-lib.nix {}; +in +insomnia.mkProfile { + name = "default"; + description = "Bundled default Insomnia coding profile"; + manifest = insomnia.mkManifest { + pod.name = "insomnia"; + + scope.allow = [ + { target = "."; permission = "write"; recursive = true; } + ]; + + session.record_event_trace = true; + + worker.reasoning = "high"; + + model.ref = "codex-oauth/gpt-5.5"; + + compaction = { + threshold = 200000; + request_threshold = 240000; + worker_context_max_tokens = 100000; + }; + + memory = { + extract_threshold = 50000; + consolidation_threshold_files = 5; + consolidation_threshold_bytes = 50000; + }; + + web = { + enabled = true; + search = { + provider = "brave"; + api_key_env = "BRAVE_SEARCH_API_KEY"; + }; + }; + }; +} From 20ac0c96a585f30ad2f9ca5c241d1eda25dd2cac Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 30 May 2026 04:34:27 +0900 Subject: [PATCH 2/2] fix: remove generic overlay startup path --- Cargo.lock | 1 + crates/client/Cargo.toml | 1 + crates/client/src/spawn.rs | 27 ++-- crates/pod/src/main.rs | 246 ++++++++++++++++------------------- crates/pod/src/spawn/tool.rs | 47 +++---- crates/tui/src/spawn.rs | 130 +++++------------- docs/architecture.md | 11 +- docs/manifest-profiles.md | 2 + docs/plan/network-peering.md | 10 +- docs/pod-factory.md | 3 +- 10 files changed, 198 insertions(+), 280 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c995d292..6d339000 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -334,6 +334,7 @@ version = "0.1.0" dependencies = [ "manifest", "protocol", + "serde_json", "tokio", "uuid", ] diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index d9722ad0..7eccbe23 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -7,5 +7,6 @@ license.workspace = true [dependencies] protocol = { workspace = true } manifest = { 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 068bdf7b..c603c7fd 100644 --- a/crates/client/src/spawn.rs +++ b/crates/client/src/spawn.rs @@ -1,8 +1,8 @@ //! `insomnia-pod` バイナリをサブプロセスとして立ち上げ、`INSOMNIA-READY` を待つ //! ハンドシェイク。 //! -//! - 親プロセス (TUI / GUI / E2E) は overlay TOML を組み立ててこの関数に -//! 渡す。pod はそれを受けて socket を bind し、stderr に +//! - 親プロセス (TUI / GUI / E2E) は profile/default/typed restore flags を +//! 指定してこの関数に渡す。pod はそれを受けて socket を bind し、stderr に //! `INSOMNIA-READY\t\t` を吐く。 //! - 待機中の stderr 行は `progress` コールバック越しに呼び出し側へ流す。 //! UI の進捗表示や E2E のログ収集はここで賄う。 @@ -28,12 +28,11 @@ pub struct SpawnConfig { /// 名前との突き合わせに使う。 pub pod_name: String, /// Optional Nix profile selector. When present the child is launched with - /// `--profile` and the TOML overlay is not passed; the Pod name is supplied - /// through `--profile-pod-name` so profile evaluation stays separate from - /// manifest layer merging and from `--pod` restore semantics. + /// `--profile`; the Pod name is supplied through `--profile-pod-name` so + /// profile evaluation stays separate from `--pod` restore semantics. pub profile: Option, - /// `--overlay` で pod に渡す TOML 文字列。 - pub overlay_toml: String, + /// Optional session-scope snapshot used when restoring by session id. + pub resume_scope: Option, /// pod の current_dir。 pub cwd: PathBuf, /// `Some(id)` のとき `--session ` を付与し、当該セッションから @@ -123,14 +122,22 @@ where .arg(profile) .arg("--profile-pod-name") .arg(&config.pod_name); - } else { - command.arg("--overlay").arg(&config.overlay_toml); } if config.resume_by_pod_name && config.profile.is_none() { command.arg("--pod").arg(&config.pod_name); } if let Some(id) = config.resume_from { - command.arg("--session").arg(id.to_string()); + command + .arg("--session") + .arg(id.to_string()) + .arg("--session-pod-name") + .arg(&config.pod_name); + if let Some(scope) = &config.resume_scope { + let scope_json = serde_json::to_string(scope).map_err(|e| { + SpawnError::PodLaunchFailed(io::Error::new(io::ErrorKind::InvalidInput, e)) + })?; + command.arg("--resume-scope-json").arg(scope_json); + } } let mut child = command.spawn().map_err(SpawnError::PodLaunchFailed)?; diff --git a/crates/pod/src/main.rs b/crates/pod/src/main.rs index b277c50d..f2c38db7 100644 --- a/crates/pod/src/main.rs +++ b/crates/pod/src/main.rs @@ -1,9 +1,10 @@ -use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::ExitCode; use clap::Parser; -use manifest::{NixProfileResolver, PodManifest, PodManifestConfig, ProfileSelector, paths}; +use manifest::{ + NixProfileResolver, PodManifest, PodManifestConfig, ProfileSelector, ScopeConfig, paths, +}; use pod::{Pod, PodController, PromptLoader}; use session_store::{FsStore, PodMetadataStore, SegmentId, Store}; @@ -19,7 +20,7 @@ struct Cli { #[arg( long, value_name = "PROFILE", - conflicts_with_all = ["manifest", "project", "overlay", "pod", "session", "adopt"] + conflicts_with_all = ["manifest", "project", "pod", "session", "adopt"] )] profile: Option, @@ -32,7 +33,7 @@ struct Cli { /// Manifest TOML to use directly as a one-file compatibility/debug input. /// This bypasses profile discovery but still applies builtin defaults and /// the same required-field validation boundary. - #[arg(long, value_name = "PATH", conflicts_with_all = ["project", "overlay"])] + #[arg(long, value_name = "PATH", conflicts_with_all = ["project"])] manifest: Option, /// Deprecated manifest-cascade project root flag. Ambient project/user @@ -40,11 +41,23 @@ struct Cli { #[arg(long, value_name = "PATH")] project: Option, - /// Inline TOML override applied to the implicit default profile. This is - /// retained for launchers that need to supply restore/session-time values; - /// it does not re-enable user/project manifest discovery. - #[arg(long, value_name = "TOML")] - overlay: Option, + /// Internal typed pod-name override for session restore launched by the TUI. + #[arg(long, value_name = "NAME", requires = "session", hide = true)] + session_pod_name: Option, + + /// Internal typed scope snapshot for session restore launched by the TUI. + #[arg(long, value_name = "JSON", requires = "session", hide = true)] + resume_scope_json: Option, + + /// Internal resolved manifest config for delegated child Pod spawning. + #[arg( + long, + value_name = "JSON", + requires = "adopt", + conflicts_with_all = ["profile", "manifest", "project", "pod", "session"], + hide = true + )] + spawn_config_json: Option, /// Directory for session persistence. Defaults to /// `/sessions/` (see `manifest::paths`). @@ -83,45 +96,56 @@ struct Cli { } fn resolve_manifest(cli: &Cli) -> Result<(PodManifest, PromptLoader), String> { - resolve_manifest_with_user_manifest_env(cli, std::env::var_os(paths::USER_MANIFEST_ENV)) + resolve_manifest_with_profile_loader(cli, load_profile) } -fn resolve_manifest_with_user_manifest_env( +fn resolve_manifest_with_profile_loader( cli: &Cli, - user_manifest_env: Option, -) -> Result<(PodManifest, PromptLoader), String> { - resolve_manifest_with_user_manifest_env_and_profile_loader(cli, user_manifest_env, load_profile) -} - -fn resolve_manifest_with_user_manifest_env_and_profile_loader( - cli: &Cli, - _user_manifest_env: Option, load_profile_fn: F, ) -> Result<(PodManifest, PromptLoader), String> where F: FnOnce(&ProfileSelector, Option<&str>) -> Result<(PodManifest, PromptLoader), String>, { - if let Some(profile) = &cli.profile { + let mut manifest_and_loader = if let Some(config_json) = cli.spawn_config_json.as_deref() { + load_spawn_config_json(config_json)? + } else if let Some(profile) = &cli.profile { let selector = ProfileSelector::parse_cli(profile); - return load_profile_fn(&selector, cli.profile_pod_name.as_deref()); - } + load_profile_fn(&selector, cli.profile_pod_name.as_deref())? + } else if let Some(path) = &cli.manifest { + load_single_manifest(path, cli.pod.as_deref())? + } else { + if cli.project.is_some() { + return Err( + "--project is no longer supported; normal startup uses profile discovery/default, \ + and --manifest is the only one-file manifest mode" + .to_string(), + ); + } + let selector = ProfileSelector::Default; + load_profile_fn(&selector, cli.pod.as_deref())? + }; - if let Some(path) = &cli.manifest { - return load_single_manifest(path, cli.pod.as_deref()); - } + apply_session_restore_overrides(&mut manifest_and_loader.0, cli)?; + Ok(manifest_and_loader) +} - if cli.project.is_some() { - return Err( - "--project is no longer supported; normal startup uses profile discovery/default, \ - and --manifest is the only one-file manifest mode" - .to_string(), - ); +fn apply_session_restore_overrides(manifest: &mut PodManifest, cli: &Cli) -> Result<(), String> { + if let Some(pod_name) = cli.session_pod_name.as_deref() { + manifest.pod.name = pod_name.to_string(); } + if let Some(scope_json) = cli.resume_scope_json.as_deref() { + manifest.scope = serde_json::from_str::(scope_json) + .map_err(|e| format!("failed to parse --resume-scope-json: {e}"))?; + } + Ok(()) +} - let selector = ProfileSelector::Default; - let (manifest, loader) = load_profile_fn(&selector, None)?; - let manifest = apply_cli_overrides_to_manifest(manifest, cli)?; - Ok((manifest, loader)) +fn load_spawn_config_json(config_json: &str) -> Result<(PodManifest, PromptLoader), String> { + let config = serde_json::from_str::(config_json) + .map_err(|e| format!("failed to parse --spawn-config-json: {e}"))?; + let manifest = PodManifest::try_from(PodManifestConfig::builtin_defaults().merge(config)) + .map_err(|e| format!("failed to resolve --spawn-config-json: {e}"))?; + Ok((manifest, PromptLoader::builtins_only())) } fn load_profile( @@ -168,53 +192,13 @@ fn load_single_manifest( .resolve_paths(base_dir), ); if let Some(pod_name) = pod_name_override { - config = config.merge( - PodManifestConfig::from_toml(&pod_name_overlay_toml(pod_name)) - .expect("pod name overlay TOML is generated"), - ); + config.pod.name = Some(pod_name.to_string()); } let manifest = PodManifest::try_from(config) .map_err(|e| format!("failed to resolve manifest {}: {e}", path.display()))?; Ok((manifest, PromptLoader::builtins_only())) } -fn pod_name_overlay_toml(pod_name: &str) -> String { - let mut pod = toml::value::Table::new(); - pod.insert("name".into(), toml::Value::String(pod_name.to_string())); - let mut root = toml::value::Table::new(); - root.insert("pod".into(), toml::Value::Table(pod)); - toml::to_string(&toml::Value::Table(root)).expect("pod name overlay serialisation cannot fail") -} - -fn manifest_to_config(manifest: &PodManifest) -> Result { - let value = serde_json::to_value(manifest) - .map_err(|e| format!("failed to serialise resolved manifest for overlay: {e}"))?; - serde_json::from_value(value) - .map_err(|e| format!("failed to convert resolved manifest for overlay: {e}")) -} - -fn apply_cli_overrides_to_manifest( - mut manifest: PodManifest, - cli: &Cli, -) -> Result { - let profile = manifest.profile.clone(); - if let Some(overlay) = cli.overlay.as_deref() { - let base_dir = std::env::current_dir() - .map_err(|e| format!("failed to resolve current directory for overlay: {e}"))?; - let overlay = PodManifestConfig::from_toml(overlay) - .map_err(|e| format!("failed to parse overlay TOML: {e}"))? - .resolve_paths(&base_dir); - let config = manifest_to_config(&manifest)?.merge(overlay); - manifest = PodManifest::try_from(config) - .map_err(|e| format!("failed to resolve default profile overlay: {e}"))?; - manifest.profile = profile; - } - if let Some(pod_name) = cli.pod.as_deref() { - manifest.pod.name = pod_name.to_string(); - } - Ok(manifest) -} - #[tokio::main] async fn main() -> ExitCode { let cli = Cli::parse(); @@ -416,7 +400,7 @@ permission = "write" } #[test] - fn manifest_conflicts_with_project_and_overlay() { + fn manifest_conflicts_with_project() { let project_err = Cli::try_parse_from([ "insomnia-pod", "--manifest", @@ -426,29 +410,23 @@ permission = "write" ]) .unwrap_err(); assert_eq!(project_err.kind(), clap::error::ErrorKind::ArgumentConflict); - - let overlay_err = Cli::try_parse_from([ - "insomnia-pod", - "--manifest", - "manifest.toml", - "--overlay", - "pod.name = 'x'", - ]) - .unwrap_err(); - assert_eq!(overlay_err.kind(), clap::error::ErrorKind::ArgumentConflict); } #[test] - fn manifest_ignores_non_empty_user_manifest_env() { + fn overlay_flag_is_not_accepted() { + let err = Cli::try_parse_from(["insomnia-pod", "--overlay", "pod.name = 'x'"]).unwrap_err(); + assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); + } + + #[test] + fn manifest_loads_single_file_without_user_or_workspace_prompt_loader() { let tmp = TempDir::new().unwrap(); let manifest = tmp.path().join("manifest.toml"); write(&manifest, &manifest_toml("single", tmp.path())); let cli = Cli::try_parse_from(["insomnia-pod", "--manifest", manifest.to_str().unwrap()]) .unwrap(); - let (manifest, loader) = - resolve_manifest_with_user_manifest_env(&cli, Some(OsString::from("user.toml"))) - .unwrap(); + let (manifest, loader) = resolve_manifest(&cli).unwrap(); assert_eq!(manifest.pod.name, "single"); assert!(loader.user_dir().is_none()); @@ -456,7 +434,7 @@ permission = "write" } #[test] - fn profile_ignores_non_empty_user_manifest_env() { + fn profile_uses_selected_profile() { let tmp = TempDir::new().unwrap(); let profile = tmp.path().join("profile.nix"); let cli = Cli::try_parse_from([ @@ -469,10 +447,8 @@ permission = "write" .unwrap(); let mut called = false; - let (manifest, loader) = resolve_manifest_with_user_manifest_env_and_profile_loader( - &cli, - Some(OsString::from("non-existent-user-manifest.toml")), - |selector, pod_name| { + let (manifest, loader) = + resolve_manifest_with_profile_loader(&cli, |selector, pod_name| { called = true; assert_eq!(selector, &ProfileSelector::path(profile.clone())); assert_eq!(pod_name, Some("from-profile-name")); @@ -482,9 +458,8 @@ permission = "write" manifest.pod.name = pod_name.to_string(); } Ok((manifest, PromptLoader::builtins_only())) - }, - ) - .unwrap(); + }) + .unwrap(); assert!(called); assert_eq!(manifest.pod.name, "from-profile-name"); @@ -505,10 +480,8 @@ permission = "write" .unwrap(); let mut called = false; - let (manifest, _loader) = resolve_manifest_with_user_manifest_env_and_profile_loader( - &cli, - None, - |selector, pod_name| { + let (manifest, _loader) = + resolve_manifest_with_profile_loader(&cli, |selector, pod_name| { called = true; assert_eq!( selector, @@ -523,40 +496,21 @@ permission = "write" manifest.pod.name = pod_name.to_string(); } Ok((manifest, PromptLoader::builtins_only())) - }, - ) - .unwrap(); + }) + .unwrap(); assert!(called); assert_eq!(manifest.pod.name, "from-profile-name"); } #[test] - fn manifest_allows_empty_user_manifest_env() { - let tmp = TempDir::new().unwrap(); - let manifest = tmp.path().join("manifest.toml"); - write(&manifest, &manifest_toml("single", tmp.path())); - let cli = Cli::try_parse_from(["insomnia-pod", "--manifest", manifest.to_str().unwrap()]) - .unwrap(); - - let (manifest, loader) = - resolve_manifest_with_user_manifest_env(&cli, Some(OsString::new())).unwrap(); - - assert_eq!(manifest.pod.name, "single"); - assert!(loader.user_dir().is_none()); - assert!(loader.workspace_dir().is_none()); - } - - #[test] - fn normal_startup_uses_default_profile_and_ignores_user_manifest_env() { + fn normal_startup_uses_default_profile() { let tmp = TempDir::new().unwrap(); let cli = Cli::try_parse_from(["insomnia-pod"]).unwrap(); let mut called = false; - let (manifest, _loader) = resolve_manifest_with_user_manifest_env_and_profile_loader( - &cli, - Some(OsString::from("ignored-user-manifest.toml")), - |selector, pod_name| { + let (manifest, _loader) = + resolve_manifest_with_profile_loader(&cli, |selector, pod_name| { called = true; assert_eq!(selector, &ProfileSelector::Default); assert_eq!(pod_name, None); @@ -564,9 +518,8 @@ permission = "write" PodManifest::from_toml(&manifest_toml("from-default-profile", tmp.path())) .unwrap(); Ok((manifest, PromptLoader::builtins_only())) - }, - ) - .unwrap(); + }) + .unwrap(); assert!(called); assert_eq!(manifest.pod.name, "from-default-profile"); @@ -575,7 +528,7 @@ permission = "write" #[test] fn project_flag_no_longer_enables_ambient_manifest_cascade() { let cli = Cli::try_parse_from(["insomnia-pod", "--project", "."]).unwrap(); - let err = resolve_manifest_with_user_manifest_env_and_profile_loader(&cli, None, |_, _| { + let err = resolve_manifest_with_profile_loader(&cli, |_, _| { panic!("default profile loader must not run when deprecated --project is present") }) .unwrap_err(); @@ -605,7 +558,7 @@ permission = "write" ]) .unwrap(); - let (manifest, _loader) = resolve_manifest_with_user_manifest_env(&cli, None).unwrap(); + let (manifest, _loader) = resolve_manifest(&cli).unwrap(); assert_eq!(manifest.pod.name, "from-flag"); } @@ -637,12 +590,37 @@ permission = "write" ]) .unwrap(); - let (manifest, _loader) = resolve_manifest_with_user_manifest_env(&cli, None).unwrap(); + let (manifest, _loader) = resolve_manifest(&cli).unwrap(); assert_eq!(manifest.pod.name, "from-flag"); assert_eq!(manifest.scope.allow[0].target, tmp.path()); } + #[test] + fn pod_flag_with_no_manifest_creates_from_default_profile_with_typed_name() { + let tmp = TempDir::new().unwrap(); + let cli = Cli::try_parse_from(["insomnia-pod", "--pod", "agent"]).unwrap(); + let mut called = false; + + let (manifest, _loader) = + resolve_manifest_with_profile_loader(&cli, |selector, pod_name| { + called = true; + assert_eq!(selector, &ProfileSelector::Default); + assert_eq!(pod_name, Some("agent")); + let mut manifest = + PodManifest::from_toml(&manifest_toml("from-default-profile", tmp.path())) + .unwrap(); + if let Some(pod_name) = pod_name { + manifest.pod.name = pod_name.to_string(); + } + Ok((manifest, PromptLoader::builtins_only())) + }) + .unwrap(); + + assert!(called); + assert_eq!(manifest.pod.name, "agent"); + } + #[test] fn profile_conflicts_with_manifest_and_restore_modes() { let segment_id = session_store::new_segment_id().to_string(); @@ -696,7 +674,7 @@ permission = "write" ]) .unwrap(); - let (manifest, loader) = resolve_manifest_with_user_manifest_env(&cli, None).unwrap(); + let (manifest, loader) = resolve_manifest(&cli).unwrap(); assert_eq!(manifest.pod.name, "single-file"); assert!(loader.user_dir().is_none()); diff --git a/crates/pod/src/spawn/tool.rs b/crates/pod/src/spawn/tool.rs index de776a40..0c21c5d7 100644 --- a/crates/pod/src/spawn/tool.rs +++ b/crates/pod/src/spawn/tool.rs @@ -1,6 +1,6 @@ //! `SpawnPod` tool — launch a new Pod process as a child of this one. //! -//! Wires pod-registry delegation, overlay-TOML construction, subprocess +//! 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 //! process group, the pod-registry is updated atomically, and the child's @@ -116,8 +116,8 @@ pub struct SpawnPodTool { /// no-op. parent_socket: Option, /// Spawner's resolved provider config — copied into every spawned - /// Pod's overlay TOML so the child does not need its own provider - /// configuration in the manifest cascade. Per-spawn override is + /// Pod's internal manifest config so the child does not need its own provider + /// configuration. Per-spawn override is /// out of scope here (see `tickets/spawn-inherit-provider.md`). spawner_model: ModelManifest, /// Spawner's runtime scope. After a successful spawn, the @@ -208,7 +208,7 @@ impl Tool for SpawnPodTool { // it back — even if later steps (Method::Run delivery, record // write) fail, the child is running and will release its own // entry on exit. - let overlay_toml = match build_overlay_toml( + let spawn_config_json = match build_spawn_config_json( &input.name, &instruction, &scope_allow, @@ -218,13 +218,13 @@ impl Tool for SpawnPodTool { Err(e) => { self.release_reservation(&lock_path, &input.name); return Err(ToolError::ExecutionFailed(format!( - "overlay serialisation: {e}" + "spawn config serialisation: {e}" ))); } }; let start_outcome = self - .exec_child(&input.name, &overlay_toml, &predicted_socket) + .exec_child(&input.name, &spawn_config_json, &predicted_socket) .await; if let Err(e) = start_outcome { self.release_reservation(&lock_path, &input.name); @@ -300,7 +300,7 @@ impl SpawnPodTool { async fn exec_child( &self, pod_name: &str, - overlay_toml: &str, + spawn_config_json: &str, predicted_socket: &Path, ) -> Result<(), ToolError> { let pod_command = @@ -329,8 +329,8 @@ impl SpawnPodTool { cmd.arg("--adopt") .arg("--callback") .arg(&self.callback_socket) - .arg("--overlay") - .arg(overlay_toml) + .arg("--spawn-config-json") + .arg(spawn_config_json) .current_dir(&self.spawner_pwd) .stdin(Stdio::null()) .stdout(Stdio::null()) @@ -382,20 +382,21 @@ fn parse_scope(rules: &[ScopeRuleInput]) -> Result, ToolError> { .collect() } -/// Serialise the overlay TOML that gets handed to the child `insomnia-pod` -/// binary via `--overlay`. `PodManifestConfig`'s `Serialize` impl is -/// the single source of truth for the on-disk manifest format. +/// Serialise the internal manifest config that gets handed to the child +/// `insomnia-pod` binary via the hidden `--spawn-config-json` flag. +/// `PodManifestConfig`'s `Serialize` impl is the single source of truth for the +/// internal handoff shape. /// /// The child's working directory is set separately via /// `Command::current_dir` (see [`SpawnPodTool::exec_child`]) — it is /// not part of the manifest. -fn build_overlay_toml( +fn build_spawn_config_json( name: &str, instruction: &str, scope_allow: &[ScopeRule], model: &ModelManifest, -) -> Result { - let overlay = PodManifestConfig { +) -> Result { + let config = PodManifestConfig { pod: PodMetaConfig { name: Some(name.to_string()), prompt_pack: None, @@ -411,7 +412,7 @@ fn build_overlay_toml( }, ..Default::default() }; - toml::to_string(&overlay) + serde_json::to_string(&config) } /// Tail of the spawned child's `stderr.log` to splice into a startup @@ -524,7 +525,7 @@ mod tests { use manifest::{AuthRef, SchemeKind}; #[test] - fn overlay_inherits_inline_spawner_model() { + fn spawn_config_inherits_inline_spawner_model() { let model = ModelManifest { scheme: Some(SchemeKind::Anthropic), base_url: Some("https://example.test".into()), @@ -536,8 +537,9 @@ mod tests { ..Default::default() }; - let toml_str = build_overlay_toml("child", "$insomnia/default", &[], &model).unwrap(); - let parsed = PodManifestConfig::from_toml(&toml_str).unwrap(); + let config_json = + build_spawn_config_json("child", "$insomnia/default", &[], &model).unwrap(); + let parsed: PodManifestConfig = serde_json::from_str(&config_json).unwrap(); assert_eq!(parsed.model.scheme, Some(SchemeKind::Anthropic)); assert_eq!(parsed.model.model_id.as_deref(), Some("claude-sonnet-4")); @@ -553,13 +555,14 @@ mod tests { } #[test] - fn overlay_inherits_ref_spawner_model() { + fn spawn_config_inherits_ref_spawner_model() { let model = ModelManifest { ref_: Some("anthropic/claude-sonnet-4-6".into()), ..Default::default() }; - let toml_str = build_overlay_toml("child", "$insomnia/default", &[], &model).unwrap(); - let parsed = PodManifestConfig::from_toml(&toml_str).unwrap(); + let config_json = + build_spawn_config_json("child", "$insomnia/default", &[], &model).unwrap(); + let parsed: PodManifestConfig = serde_json::from_str(&config_json).unwrap(); assert_eq!( parsed.model.ref_.as_deref(), Some("anthropic/claude-sonnet-4-6") diff --git a/crates/tui/src/spawn.rs b/crates/tui/src/spawn.rs index 199bc0bf..3265660e 100644 --- a/crates/tui/src/spawn.rs +++ b/crates/tui/src/spawn.rs @@ -104,7 +104,6 @@ pub async fn run( let mut form = Form { cwd: defaults.cwd.clone(), - cascade_has_scope: defaults.cascade_has_scope, scope_origin: defaults.scope_origin, name_cursor: defaults.default_name.chars().count(), name: defaults.default_name, @@ -153,7 +152,6 @@ pub async fn run( if let Some(id) = form.resume_from { form.resume_scope = Some(load_resume_scope(id).await?); } - let overlay_toml = build_overlay_toml(&form); // Phase 2: launch pod and wait for ready line. Drop the cursor // out of the name field — subsequent frames are passive status @@ -163,7 +161,7 @@ pub async fn run( form.message = Some(("starting pod...".to_string(), MessageKind::Progress)); terminal.draw(|f| draw_form(f, &form))?; - match wait_for_ready(&mut terminal, &mut form, &overlay_toml).await { + match wait_for_ready(&mut terminal, &mut form).await { Ok(ready) => { form.message = Some(( format!("ready: {} attaching...", ready.pod_name), @@ -184,15 +182,14 @@ pub async fn run( /// Launch `insomnia-pod --pod ` without opening the name dialog. The child Pod /// resolves persisted Pod metadata if present, or creates a fresh same-name Pod -/// with the usual TUI cwd-scope fallback. +/// from the default profile. pub async fn run_pod_name(pod_name: String) -> Result { let defaults = load_spawn_defaults()?; let mut form = form_for_pod_name(pod_name, defaults); - let overlay_toml = build_overlay_toml(&form); let mut terminal = make_inline_terminal()?; terminal.draw(|f| draw_form(f, &form))?; - match wait_for_ready(&mut terminal, &mut form, &overlay_toml).await { + match wait_for_ready(&mut terminal, &mut form).await { Ok(ready) => { form.message = Some(( format!("ready: {} attaching...", ready.pod_name), @@ -213,7 +210,6 @@ pub async fn run_pod_name(pod_name: String) -> Result struct SpawnDefaults { cwd: PathBuf, - cascade_has_scope: bool, scope_origin: ScopeOrigin, default_name: String, default_profile_index: usize, @@ -241,7 +237,6 @@ fn load_spawn_defaults() -> Result { Ok(SpawnDefaults { cwd, - cascade_has_scope: true, scope_origin: ScopeOrigin::FromProfile, default_name, default_profile_index, @@ -303,7 +298,6 @@ fn initial_profile_index( fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form { Form { cwd: defaults.cwd, - cascade_has_scope: defaults.cascade_has_scope, scope_origin: defaults.scope_origin, name_cursor: pod_name.chars().count(), name: pod_name, @@ -385,15 +379,12 @@ fn sanitise_default_name(s: &str) -> String { async fn wait_for_ready( terminal: &mut InlineTerminal, form: &mut Form, - overlay_toml: &str, ) -> Result { - let cwd = std::env::current_dir().map_err(SpawnError::Io)?; - let config = SpawnConfig { pod_name: form.name.clone(), profile: form.selected_profile_selector(), - overlay_toml: overlay_toml.to_string(), - cwd, + resume_scope: form.resume_scope.clone(), + cwd: form.cwd.clone(), resume_from: form.resume_from, resume_by_pod_name: form.resume_by_pod_name, }; @@ -408,36 +399,6 @@ async fn wait_for_ready( }) } -fn build_overlay_toml(form: &Form) -> String { - let mut root = toml::value::Table::new(); - - let mut pod = toml::value::Table::new(); - pod.insert("name".into(), toml::Value::String(form.name.clone())); - root.insert("pod".into(), toml::Value::Table(pod)); - - if let Some(scope_config) = form.resume_scope.as_ref() { - root.insert( - "scope".into(), - toml::Value::try_from(scope_config).expect("scope serialisation cannot fail"), - ); - } else if !form.cascade_has_scope { - let mut rule = toml::value::Table::new(); - rule.insert( - "target".into(), - toml::Value::String(form.cwd.display().to_string()), - ); - rule.insert("permission".into(), toml::Value::String("write".into())); - let mut scope = toml::value::Table::new(); - scope.insert( - "allow".into(), - toml::Value::Array(vec![toml::Value::Table(rule)]), - ); - root.insert("scope".into(), toml::Value::Table(scope)); - } - - toml::to_string(&toml::Value::Table(root)).expect("overlay serialisation cannot fail") -} - async fn load_resume_scope(segment_id: SegmentId) -> Result { let store_dir = manifest::paths::sessions_dir().ok_or_else(|| { io::Error::new( @@ -466,14 +427,10 @@ enum MessageKind { enum ScopeOrigin { FromProfile, - CwdDefault, } struct Form { cwd: PathBuf, - /// True when the launch source already supplies `scope.allow`. - /// Drives whether the compatibility overlay should add a cwd-write rule. - cascade_has_scope: bool, /// Display label for the scope row in the dialog. scope_origin: ScopeOrigin, name: String, @@ -497,8 +454,8 @@ struct Form { /// resolves name-keyed state before falling back to fresh creation. resume_by_pod_name: bool, /// Scope snapshot recovered from the source session log. Set only for - /// resume runs, and serialized into the overlay instead of cwd-default - /// scope so resume does not silently broaden access. + /// resume runs and passed through a typed internal restore flag so resume + /// does not silently broaden access. resume_scope: Option, /// Optional Nix profile choices passed to `insomnia-pod --profile` for /// fresh spawns. This is not used for resume/attach flows because those must @@ -659,20 +616,22 @@ fn context_line(form: &Form) -> Line<'_> { ]); } + if form.resume_scope.is_some() { + return Line::from(vec![ + Span::raw(" "), + Span::styled("scope: ", Style::default().fg(Color::DarkGray)), + Span::styled( + "from restored session snapshot", + Style::default().fg(Color::Green), + ), + ]); + } + match form.scope_origin { ScopeOrigin::FromProfile => Line::from(vec![ Span::raw(" "), Span::styled("scope: ", Style::default().fg(Color::DarkGray)), - Span::styled("from default profile", Style::default().fg(Color::Green)), - ]), - ScopeOrigin::CwdDefault => Line::from(vec![ - Span::raw(" "), - Span::styled("scope: ", Style::default().fg(Color::DarkGray)), - Span::styled( - form.cwd.display().to_string(), - Style::default().fg(Color::Yellow), - ), - Span::styled(" (write, default)", Style::default().fg(Color::DarkGray)), + Span::styled("from selected profile", Style::default().fg(Color::Green)), ]), } } @@ -701,15 +660,10 @@ fn message_line(form: &Form) -> Line<'_> { mod tests { use super::*; - fn form(name: &str, cascade_has_scope: bool) -> Form { + fn form(name: &str) -> Form { Form { cwd: PathBuf::from("/work/example"), - cascade_has_scope, - scope_origin: if cascade_has_scope { - ScopeOrigin::FromProfile - } else { - ScopeOrigin::CwdDefault - }, + scope_origin: ScopeOrigin::FromProfile, name: name.to_string(), name_cursor: name.chars().count(), message: None, @@ -726,7 +680,6 @@ mod tests { fn pod_name_form_restores_or_creates_by_pod_name() { let defaults = SpawnDefaults { cwd: PathBuf::from("/work/example"), - cascade_has_scope: true, scope_origin: ScopeOrigin::FromProfile, default_name: "ignored".to_string(), default_profile_index: 0, @@ -747,29 +700,8 @@ mod tests { } #[test] - fn overlay_adds_scope_default_when_cascade_lacks_scope() { - let f = form("agent-1", false); - let toml_str = build_overlay_toml(&f); - let parsed: toml::Value = toml::from_str(&toml_str).unwrap(); - assert_eq!(parsed["pod"]["name"].as_str(), Some("agent-1")); - let allow = parsed["scope"]["allow"].as_array().unwrap(); - assert_eq!(allow.len(), 1); - assert_eq!(allow[0]["target"].as_str(), Some("/work/example")); - assert_eq!(allow[0]["permission"].as_str(), Some("write")); - } - - #[test] - fn overlay_omits_scope_when_cascade_already_has_one() { - let f = form("agent-2", true); - let toml_str = build_overlay_toml(&f); - let parsed: toml::Value = toml::from_str(&toml_str).unwrap(); - assert_eq!(parsed["pod"]["name"].as_str(), Some("agent-2")); - assert!(parsed.get("scope").is_none()); - } - - #[test] - fn overlay_uses_resume_scope_snapshot() { - let mut f = form("agent-r", false); + fn resume_scope_snapshot_stays_on_form_for_typed_restore_flag() { + let mut f = form("agent-r"); f.resume_from = Some(session_store::new_segment_id()); f.resume_scope = Some(ScopeConfig { allow: vec![manifest::ScopeRule { @@ -783,12 +715,10 @@ mod tests { recursive: true, }], }); - let toml_str = build_overlay_toml(&f); - let parsed: toml::Value = toml::from_str(&toml_str).unwrap(); - assert_eq!(parsed["pod"]["name"].as_str(), Some("agent-r")); - assert_eq!(parsed["scope"]["allow"].as_array().unwrap().len(), 1); - let deny = parsed["scope"]["deny"].as_array().unwrap(); - assert_eq!(deny[0]["target"].as_str(), Some("/work/example/child")); + + let scope = f.resume_scope.as_ref().unwrap(); + assert_eq!(scope.allow[0].target, PathBuf::from("/work/example")); + assert_eq!(scope.deny[0].target, PathBuf::from("/work/example/child")); } #[test] @@ -841,8 +771,8 @@ description = "Project coder" } #[test] - fn profile_cycle_selects_profiles_without_manifest_cascade_opt_out() { - let mut form = form("coder", true); + fn profile_cycle_selects_only_discovered_profiles() { + let mut form = form("coder"); form.profile_choices = vec![ ProfileChoice { selector: Some("project:coder".to_string()), @@ -889,7 +819,7 @@ description = "Project coder" #[test] fn name_input_handles_insert_backspace_and_cursor() { - let mut f = form("", false); + let mut f = form(""); for c in "abc".chars() { f.insert_char(c); } diff --git a/docs/architecture.md b/docs/architecture.md index 061bd811..98547255 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -89,16 +89,13 @@ permission = "write" `[model]` は `ref = "/"` でプロバイダ / モデルカタログを引く短縮形と、`scheme` / `model_id` / `auth` を直書きする inline 形式の両方を受ける。カタログは `resources/{providers,models}/builtin.toml` を builtin、`/{providers,models}.toml` を user override として解決する(`` の解決ルールは `manifest::paths` 参照)。詳細は `docs/pod-factory.md` と `crates/provider/README.md`。 -### PodFactory: カスケード設定 +### Manifest / profile 入力 -マニフェストを手書きせず、4 層のカスケードで `PodManifest` を組み立てる: +通常の Pod 起動は Nix profile discovery/default から `PodManifest` を生成する。bundled `builtin:default` が fallback default で、user/project `profiles.toml` は profile registry と default selection だけを担う。user/project `manifest.toml` の ambient cascade は通常起動では使わない。 -1. **ビルトインデフォルト** — `manifest::defaults` の定数値 -2. **ユーザー manifest** — `/manifest.toml`(`manifest::paths` で解決) -3. **プロジェクト manifest** — `.insomnia/manifest.toml`(cwd から上方向に探索) -4. **プログラマティック overlay** — CLI / GUI / spawn 時のインライン指定 +`insomnia-pod --manifest ` は explicit one-file compatibility/debug input で、指定 TOML 1 枚だけに builtin defaults を merge し、`PodManifestConfig -> PodManifest` の required validation を通す。 -マージ規則: スカラーは上層が置換、Map はキー単位マージ、`scope.allow` / `scope.deny` は union。全パスは絶対パスのみ。 +`PodFactory` の user/project/overlay API は低レベル構成部品として残るが、CLI の通常起動 path では generic TOML overlay を公開しない。 ### Instruction とプロンプト資産 diff --git a/docs/manifest-profiles.md b/docs/manifest-profiles.md index 20405b33..7a748067 100644 --- a/docs/manifest-profiles.md +++ b/docs/manifest-profiles.md @@ -87,6 +87,8 @@ A profile should evaluate to one of: The resolved artifact is deserialized into the same `PodManifestConfig -> PodManifest` boundary used by direct one-file manifests, so builtin defaults and required-field validation stay shared. Explicit profile paths and user/project registry profile artifacts resolve relative manifest paths against the profile file's directory. Builtin profile artifacts resolve manifest-relative paths against the launch workspace/current directory so the bundled default can grant `scope.allow target = "."` for the workspace rather than for `resources/nix/profiles`. +Profile and one-file manifest CLI paths currently use builtin prompt assets only. `$insomnia/...` instruction refs work; `$user/...` and `$workspace/...` prompt refs need a future explicit prompt-loader source design instead of reviving ambient manifest discovery. + Secret values must stay as typed references. `resources/nix/profile-lib.nix` emits secret references as JSON like: ```json diff --git a/docs/plan/network-peering.md b/docs/plan/network-peering.md index 3ff60d83..f12007ae 100644 --- a/docs/plan/network-peering.md +++ b/docs/plan/network-peering.md @@ -192,8 +192,8 @@ host_a (spawner) host_b (remote) Pod A (pod binary + ssh のみ) │ ├── ssh: session データを転送 ────────→ ファイル書き込み - ├── ssh: overlay TOML を転送 ─────────→ ファイル書き込み - ├── ssh: `insomnia-pod --overlay ... &` ───────→ Pod プロセス起動、socket 作成 + ├── ssh: profile / one-file manifest 入力を転送 ─→ 必要ならファイル書き込み + ├── ssh: `insomnia-pod --profile ... &` ───────→ Pod プロセス起動、socket 作成 ├── ssh -L: socket を tunnel ─────────→ Pod B の unix socket │ └── localhost:tunnel に接続 ──────────→ Method::Run / Event stream @@ -203,14 +203,14 @@ host_a (spawner) host_b (remote) ### コマンドイメージ ```bash -# 1. session + overlay を転送 +# 1. session + profile/manifest input を転送 ssh insomnia@host-b "mkdir -p ~/workspaces/task-123/store" tar cz session/ | ssh insomnia@host-b "tar xz -C ~/workspaces/task-123/store" -echo "$OVERLAY" | ssh insomnia@host-b "cat > ~/workspaces/task-123/overlay.toml" +scp profile.nix insomnia@host-b:~/workspaces/task-123/profile.nix # 2. Pod を起動(detach) ssh insomnia@host-b "insomnia-pod --store ~/workspaces/task-123/store \ - --overlay ~/workspaces/task-123/overlay.toml &" + --profile ~/workspaces/task-123/profile.nix &" # 3. socket を tunnel で引っ張る ssh -L /tmp/pod-b.sock:/run/insomnia/task-123/pod.sock insomnia@host-b diff --git a/docs/pod-factory.md b/docs/pod-factory.md index 09da786c..ba24048e 100644 --- a/docs/pod-factory.md +++ b/docs/pod-factory.md @@ -347,7 +347,6 @@ insomnia-pod [--profile ] [--profile-pod-name ] [-s/--store ` | builtin/user/project profile registry から Nix profile を選択。省略時は registry default(通常は `builtin:default`) | | `--profile-pod-name ` | profile 由来 manifest の `pod.name` を fresh spawn 用に上書き | -| `--overlay ` | TUI/launcher compatibility 用の inline override。user/project manifest discovery は行わない | | `-s, --store ` | セッション永続化ディレクトリ(デフォルト: `/sessions/`、`manifest::paths` で解決) | | `--session ` | 既存 session id から Pod を復元し、同じ jsonl に後続 turn を追記する | @@ -357,7 +356,7 @@ insomnia-pod [--profile ] [--profile-pod-name ] [-s/--store [-s/--store ] [--session ] ``` -`--manifest` は指定 TOML 1 枚だけを読み、builtin defaults を merge したうえで `PodManifestConfig -> PodManifest` の required validation を通す。user / project manifest layer は読まない。`--profile`、`--project`、`--overlay` とは併用不可。 +`--manifest` は指定 TOML 1 枚だけを読み、builtin defaults を merge したうえで `PodManifestConfig -> PodManifest` の required validation を通す。user / project manifest layer は読まない。`--profile`、`--project` とは併用不可。 spawn 子 Pod 用の内部フラグとして `--adopt` と `--callback ` がある。これらは `SpawnPod` が scope allocation と親 callback socket を引き継がせるために使うもので、通常の手動起動では使わない。