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 を引き継がせるために使うもので、通常の手動起動では使わない。