From cba46c6a39a98123f76bfd16ed621814ad2f2b64 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 30 May 2026 11:45:49 +0900 Subject: [PATCH] feat: add lua profile authoring --- Cargo.lock | 102 ++- crates/client/src/spawn.rs | 2 +- crates/manifest/Cargo.toml | 1 + crates/manifest/src/lib.rs | 8 +- crates/manifest/src/paths.rs | 6 +- crates/manifest/src/profile.rs | 1356 ++++++++++++++++++++------------ crates/pod/src/main.rs | 18 +- crates/tui/src/spawn.rs | 2 +- docs/architecture.md | 2 +- docs/manifest-profiles.md | 82 +- docs/pod-factory.md | 4 +- resources/profiles/default.lua | 42 + 12 files changed, 1058 insertions(+), 567 deletions(-) create mode 100644 resources/profiles/default.lua diff --git a/Cargo.lock b/Cargo.lock index 23e533d9..8025453d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -721,6 +721,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -1714,6 +1725,25 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lua-src" +version = "550.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e836dc8ae16806c9bdcf42003a88da27d163433e3f9684c52f0301258004a4fb" +dependencies = [ + "cc", +] + +[[package]] +name = "luajit-src" +version = "210.6.6+707c12b" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a86cc925d4053d0526ae7f5bc765dbd0d7a5d1a63d43974f4966cb349ca63295" +dependencies = [ + "cc", + "which", +] + [[package]] name = "mac_address" version = "1.1.8" @@ -1730,6 +1760,7 @@ version = "0.1.0" dependencies = [ "arc-swap", "llm-worker", + "mlua", "protocol", "serde", "serde_ignored", @@ -1841,6 +1872,39 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mlua" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd36acfa49ce6ee56d1307a061dd302c564eee757e6e4cd67eb4f7204846fab" +dependencies = [ + "bstr", + "either", + "erased-serde", + "libc", + "mlua-sys", + "num-traits", + "parking_lot", + "rustc-hash", + "rustversion", + "serde", + "serde-value", +] + +[[package]] +name = "mlua-sys" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f1c3a7fc7580227ece249fd90aa2fa3b39eb2b49d3aec5e103b3e85f2c3dfc8" +dependencies = [ + "cc", + "cfg-if", + "libc", + "lua-src", + "luajit-src", + "pkg-config", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -1997,6 +2061,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-float" version = "4.6.0" @@ -2876,6 +2949,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float 2.10.1", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -3288,7 +3371,7 @@ dependencies = [ "nix", "num-derive", "num-traits", - "ordered-float", + "ordered-float 4.6.0", "pest", "pest_derive", "phf", @@ -3679,6 +3762,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.19.0" @@ -4024,7 +4113,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" dependencies = [ "log", - "ordered-float", + "ordered-float 4.6.0", "strsim", "thiserror 1.0.69", "wezterm-dynamic-derive", @@ -4054,6 +4143,15 @@ dependencies = [ "wezterm-dynamic", ] +[[package]] +name = "which" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +dependencies = [ + "libc", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/crates/client/src/spawn.rs b/crates/client/src/spawn.rs index 401cff03..ad448a70 100644 --- a/crates/client/src/spawn.rs +++ b/crates/client/src/spawn.rs @@ -27,7 +27,7 @@ pub struct SpawnConfig { /// (`manifest::paths::pod_runtime_dir`) の解決と、ready 行に乗る /// 名前との突き合わせに使う。 pub pod_name: String, - /// Optional Nix profile selector. When present the child is launched with + /// Optional profile selector. When present the child is launched with /// `--profile`; the Pod name is supplied through `--profile-pod-name` so /// profile evaluation stays separate from `--pod` restore semantics. pub profile: Option, diff --git a/crates/manifest/Cargo.toml b/crates/manifest/Cargo.toml index d4b014f9..3af3ae4c 100644 --- a/crates/manifest/Cargo.toml +++ b/crates/manifest/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] arc-swap = "1" llm-worker = { workspace = true } +mlua = { version = "0.11.4", features = ["lua54", "vendored", "serialize"] } protocol = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 7cffbe07..bd99ba2f 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -20,9 +20,9 @@ pub use paths::{ user_profiles_path, }; pub use profile::{ - NixProfileResolver, ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, - ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource, ProfileSelector, ProfileSource, - ResolvedProfile, resolve_profile_artifact, + ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry, + ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, + ProfileSelector, ProfileSource, ResolvedProfile, resolve_profile_artifact, }; pub use protocol::{Permission, ScopeRule}; pub use scope::{Scope, ScopeError, SharedScope}; @@ -74,7 +74,7 @@ pub struct PodManifest { /// there is no implicit `$config_dir/skills/` or builtin probe. #[serde(default)] pub skills: Option, - /// Optional profile provenance for manifests produced by a Nix profile. + /// Optional profile provenance for manifests produced by profile resolution. /// Stored only after profile resolution so Pod restore can prefer the /// validated snapshot over ambient manifest cascade state. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/manifest/src/paths.rs b/crates/manifest/src/paths.rs index ed3a23ef..71b607c4 100644 --- a/crates/manifest/src/paths.rs +++ b/crates/manifest/src/paths.rs @@ -145,10 +145,10 @@ pub fn resource_dir() -> Option { ) } -/// Bundled profile registry directory. Missing directories are treated as an -/// empty builtin registry by discovery. +/// Bundled Lua profile registry directory. Missing directories are treated as +/// an empty builtin registry by discovery. pub fn builtin_profiles_dir() -> Option { - Some(resource_dir()?.join("nix").join("profiles")) + Some(resource_dir()?.join("profiles")) } /// `/prompts.toml` — user prompt pack。 diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 04daa97a..9578a0ed 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -1,21 +1,29 @@ -//! Nix profile resolution. +//! Lua profile discovery and resolution. //! -//! Profiles are a human-authored Nix entrypoint that evaluates to a typed -//! resolved artifact. Rust consumes the evaluated JSON artifact directly and -//! validates it into the existing [`crate::PodManifest`] runtime contract. +//! Profiles are reusable, human-authored recipes. They are intentionally not +//! complete runtime manifests: runtime-bound and authority-bearing fields such +//! as `pod.name` and concrete `scope.allow` rules are supplied by the resolver +//! from launch context. -use std::collections::BTreeMap; +use std::cell::RefCell; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::rc::Rc; +use mlua::{Lua, LuaOptions, LuaSerdeExt, RegistryKey, StdLib, Table, Value as LuaValue}; use serde::{Deserialize, Serialize}; -use crate::{PodManifest, PodManifestConfig, ResolveError, paths}; +use crate::config::{CompactionConfigPartial, PermissionConfigPartial, SessionConfigPartial}; +use crate::model::{AuthRef, ModelManifest}; +use crate::{ + MemoryConfig, Permission, PodManifest, PodManifestConfig, PodMetaConfig, ResolveError, + ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifestConfig, paths, +}; -const PROFILE_FORMAT_V1: &str = "insomnia.nix-profile.v1"; +const PROFILE_FORMAT_V1: &str = "insomnia.lua-profile.v1"; const BUILTIN_DEFAULT_PROFILE_NAME: &str = "default"; +const DEFAULT_POD_NAME: &str = "insomnia"; -/// Registry source for discovered profiles. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ProfileRegistrySource { @@ -49,19 +57,17 @@ impl std::fmt::Display for ProfileRegistrySource { } } -/// User selection of a profile source. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ProfileSelector { - /// A local Nix expression evaluated with `nix eval --json --file `. - Path { path: PathBuf }, - /// A named profile discovered from builtin/user/project registries. + Path { + path: PathBuf, + }, Named { #[serde(default, skip_serializing_if = "Option::is_none")] source: Option, name: String, }, - /// The effective default from the discovered profile registry. Default, } @@ -69,29 +75,18 @@ impl ProfileSelector { pub fn path(path: impl Into) -> Self { Self::Path { path: path.into() } } - pub fn named(name: impl Into) -> Self { Self::Named { source: None, name: name.into(), } } - pub fn source_named(source: ProfileRegistrySource, name: impl Into) -> Self { Self::Named { source: Some(source), name: name.into(), } } - - /// Parse the CLI/TUI `--profile` argument. - /// - /// `path:` always selects an explicit path. `builtin:`, - /// `user:`, and `project:` require the given registry source. - /// Unqualified path-like values (containing `/`, starting with `.`, or ending - /// in `.nix`) remain compatible with the original explicit-path flow; other - /// values are discovered names. `default` asks discovery for the effective - /// default profile. pub fn parse_cli(raw: &str) -> Self { if raw == "default" { return Self::Default; @@ -99,18 +94,21 @@ impl ProfileSelector { if let Some(path) = raw.strip_prefix("path:") { return Self::path(path); } - if let Some((prefix, name)) = raw.split_once(':') { - if let Some(source) = ProfileRegistrySource::parse(prefix) { - return Self::source_named(source, name); - } + if let Some((prefix, name)) = raw.split_once(':') + && let Some(source) = ProfileRegistrySource::parse(prefix) + { + return Self::source_named(source, name); } - if raw.contains('/') || raw.starts_with('.') || raw.ends_with(".nix") { + if raw.contains('/') + || raw.starts_with('.') + || raw.ends_with(".lua") + || raw.ends_with(".nix") + { Self::path(raw) } else { Self::named(raw) } } - pub fn display_label(&self) -> String { match self { Self::Path { path } => path.display().to_string(), @@ -123,7 +121,6 @@ impl ProfileSelector { } } -/// Profile source recorded with a resolved artifact. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ProfileSource { @@ -137,7 +134,6 @@ pub enum ProfileSource { }, } -/// One profile discovered from a registry source. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProfileRegistryEntry { pub source: ProfileRegistrySource, @@ -153,10 +149,6 @@ impl ProfileRegistryEntry { } } -/// Discovered profile registry. User/project `profiles.toml` files contribute -/// only profile discovery metadata (entries and defaults); those files are -/// application/project UX configuration, not Pod runtime manifests and are not -/// merged into the selected profile's runtime manifest. #[derive(Debug, Clone, Default)] pub struct ProfileRegistry { entries: Vec, @@ -167,7 +159,6 @@ impl ProfileRegistry { pub fn entries(&self) -> &[ProfileRegistryEntry] { &self.entries } - pub fn default_entry(&self) -> Result<&ProfileRegistryEntry, ProfileError> { let default = self .default @@ -175,20 +166,18 @@ impl ProfileRegistry { .ok_or(ProfileError::NoDefaultProfile)?; self.select_named(default.source, &default.name) } - pub fn select( &self, selector: &ProfileSelector, ) -> Result<&ProfileRegistryEntry, ProfileError> { match selector { - ProfileSelector::Path { .. } => Err(ProfileError::InvalidArtifact( - "path selectors are not registry entries".to_string(), + ProfileSelector::Path { .. } => Err(ProfileError::InvalidProfile( + "path selectors are not registry entries".into(), )), ProfileSelector::Default => self.default_entry(), ProfileSelector::Named { source, name } => self.select_named(*source, name), } } - fn select_named( &self, source: Option, @@ -202,10 +191,7 @@ impl ProfileRegistry { match matches.as_slice() { [entry] => Ok(*entry), [] => Err(ProfileError::ProfileNotFound { - selector: match source { - Some(source) => format!("{source}:{name}"), - None => name.to_string(), - }, + selector: source.map_or_else(|| name.to_string(), |s| format!("{s}:{name}")), }), _ => Err(ProfileError::AmbiguousProfileName { name: name.to_string(), @@ -213,15 +199,12 @@ impl ProfileRegistry { }), } } - fn push_entry(&mut self, entry: ProfileRegistryEntry) { self.entries.push(entry); } - fn set_default(&mut self, default: ProfileDefault) { self.default = Some(default); } - fn set_builtin_default_if_available(&mut self) { if self.default.is_some() { return; @@ -239,7 +222,6 @@ impl ProfileRegistry { }); } } - fn mark_default_flags(&mut self) { let Some(default) = self.default.clone() else { return; @@ -261,7 +243,6 @@ struct ProfileDefault { name: String, } -/// Filesystem-backed profile discovery. #[derive(Debug, Clone)] pub struct ProfileDiscovery { builtin_dir: Option, @@ -277,7 +258,6 @@ impl ProfileDiscovery { project_config: find_project_profiles_from(cwd), } } - pub fn with_sources( builtin_dir: Option, user_config: Option, @@ -289,7 +269,6 @@ impl ProfileDiscovery { project_config, } } - pub fn discover(&self) -> Result { let mut registry = ProfileRegistry::default(); if let Some(dir) = &self.builtin_dir { @@ -307,7 +286,6 @@ impl ProfileDiscovery { } } -/// Metadata optionally emitted by `mkProfile`. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ProfileMetadata { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -318,7 +296,6 @@ pub struct ProfileMetadata { pub format: Option, } -/// Profile provenance embedded in a resolved manifest snapshot. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ProfileManifestSnapshot { pub source: ProfileSource, @@ -326,60 +303,52 @@ pub struct ProfileManifestSnapshot { pub profile: Option, } -/// Validated result of evaluating and resolving a profile. #[derive(Debug, Clone)] pub struct ResolvedProfile { pub source: ProfileSource, pub profile: Option, pub manifest: PodManifest, - /// The validated runtime manifest as JSON. This is the snapshot shape future - /// Pod restore should prefer over re-evaluating the Nix source. pub manifest_snapshot: serde_json::Value, - /// Raw JSON returned by Nix, retained for diagnostics/debugging. pub raw_artifact: serde_json::Value, } -/// External-command based Nix resolver. -#[derive(Debug, Clone)] -pub struct NixProfileResolver { - nix_bin: PathBuf, +#[derive(Debug, Clone, Default)] +pub struct ProfileResolveOptions { + pub pod_name: Option, +} +impl ProfileResolveOptions { + pub fn with_pod_name(name: impl Into) -> Self { + Self { + pod_name: Some(name.into()), + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct ProfileResolver { workspace_base: Option, } -impl Default for NixProfileResolver { - fn default() -> Self { - Self { - nix_bin: PathBuf::from("nix"), - workspace_base: None, - } - } -} - -impl NixProfileResolver { +impl ProfileResolver { pub fn new() -> Self { Self::default() } - - 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 { + pub fn resolve( + &self, + selector: &ProfileSelector, + options: ProfileResolveOptions, + ) -> Result { match selector { ProfileSelector::Path { path } => self.resolve_path( path, ProfileSource::Path { path: absolutize(path)?, }, - None, + options, ), ProfileSelector::Named { .. } | ProfileSelector::Default => { let cwd = std::env::current_dir().map_err(|source| ProfileError::CommandIo { @@ -388,11 +357,6 @@ 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 { @@ -400,133 +364,172 @@ impl NixProfileResolver { name: entry.name, path: absolutize(&entry.path)?, }, - artifact_base, + options, ) } } } - fn resolve_path( &self, path: &Path, source: ProfileSource, - manifest_base_override: Option, + options: ProfileResolveOptions, ) -> Result { let absolute_path = absolutize(path)?; - let file_base_dir = absolute_path + let extension = absolute_path + .extension() + .and_then(|s| s.to_str()) + .map(str::to_string); + match extension.as_deref() { + Some("lua") => {} + Some("nix") => return Err(ProfileError::UnsupportedProfileType { path: absolute_path, message: "Nix profile evaluation is not part of the primary Profile path; use a Lua profile or --manifest for a complete Manifest".into() }), + other => return Err(ProfileError::UnsupportedProfileType { path: absolute_path, message: format!("unsupported profile extension {}; Lua profiles must end in .lua", other.map_or("".to_string(), |s| format!(".{s}"))) }), + } + let profile_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(), + message: "profile path has no parent directory".into(), })?; - let base_dir = manifest_base_override.unwrap_or(file_base_dir); - - let output = Command::new(&self.nix_bin) - .arg("eval") - .arg("--json") - .arg("--file") - .arg(&absolute_path) - .output() - .map_err(|source| { - if source.kind() == std::io::ErrorKind::NotFound { - ProfileError::NixUnavailable { - nix_bin: self.nix_bin.clone(), - profile: absolute_path.clone(), - } - } else { - ProfileError::CommandIo { - path: absolute_path.clone(), - source, - } - } - })?; - - if !output.status.success() { - return Err(ProfileError::NixFailed { - path: absolute_path, - status: output.status.code(), - stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), - }); - } - - let raw_artifact: serde_json::Value = - serde_json::from_slice(&output.stdout).map_err(|source| ProfileError::JsonParse { - path: absolute_path, - source, - })?; - - resolve_profile_artifact(source, &base_dir, raw_artifact) + let profile_dir = canonicalize_existing_dir(&profile_dir)?; + let workspace_base = absolutize( + self.workspace_base + .as_deref() + .unwrap_or_else(|| Path::new(".")), + )?; + let lua_value = evaluate_lua_profile(&absolute_path, &profile_dir)?; + let raw_artifact = lua_value.clone(); + resolve_lua_profile_value( + source, + &profile_dir, + &workspace_base, + options, + lua_value, + raw_artifact, + ) } } -/// Resolve an already-evaluated profile artifact. Tests and future non-Nix -/// resolvers use this to share artifact validation semantics. -pub fn resolve_profile_artifact( +fn resolve_lua_profile_value( source: ProfileSource, - base_dir: &Path, + profile_dir: &Path, + workspace_base: &Path, + options: ProfileResolveOptions, + value: serde_json::Value, raw_artifact: serde_json::Value, ) -> Result { - if !base_dir.is_absolute() { + if !workspace_base.is_absolute() { return Err(ProfileError::InvalidPath { - path: base_dir.to_path_buf(), - message: "profile base directory must be absolute".to_string(), + path: workspace_base.to_path_buf(), + message: "profile workspace base must be absolute".into(), }); } - - let envelope: ProfileEnvelope = serde_json::from_value(raw_artifact.clone()) - .map_err(|source| ProfileError::ArtifactShape { source })?; - envelope.validate_format()?; - - let manifest_value = extract_manifest_value(&raw_artifact)?; - let config: PodManifestConfig = serde_json::from_value(manifest_value.clone()) - .map_err(|source| ProfileError::ManifestDeserialize { source })?; - let config = PodManifestConfig::builtin_defaults().merge(config.resolve_paths(base_dir)); + reject_manifest_shaped_profile(&value)?; + let profile: ProfileConfig = serde_json::from_value(value.clone()) + .map_err(|source| ProfileError::ProfileDeserialize { source })?; + validate_profile_paths(&profile)?; + let pod_name = options + .pod_name + .unwrap_or_else(|| derive_pod_name(&source, profile.slug.as_deref())); + let profile_meta = Some(ProfileMetadata { + name: profile.slug.clone().or_else(|| source_name(&source)), + description: profile.description.clone(), + format: Some(PROFILE_FORMAT_V1.to_string()), + }); + let compaction = profile_compaction_to_partial(profile.compaction, &profile.model)?; + let config = PodManifestConfig { + pod: PodMetaConfig { + name: Some(pod_name), + prompt_pack: None, + }, + model: profile.model.unwrap_or_default(), + worker: profile.worker.unwrap_or_default(), + scope: profile_scope_to_config(profile.scope, workspace_base), + session: profile.session, + permissions: profile.permissions, + compaction, + web: profile.web, + memory: profile.memory, + skills: profile.skills, + }; + let config = PodManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir)); let mut manifest = PodManifest::try_from(config).map_err(ProfileError::ManifestResolve)?; manifest.profile = Some(ProfileManifestSnapshot { source: source.clone(), - profile: envelope.profile.clone(), + profile: profile_meta.clone(), }); let manifest_snapshot = serde_json::to_value(&manifest).map_err(ProfileError::SnapshotSerialize)?; - Ok(ResolvedProfile { source, - profile: envelope.profile, + profile: profile_meta, manifest, manifest_snapshot, raw_artifact, }) } -#[derive(Debug, Deserialize)] -struct ProfileEnvelope { - #[serde(default)] - profile: Option, -} - -impl ProfileEnvelope { - fn validate_format(&self) -> Result<(), ProfileError> { - let Some(profile) = &self.profile else { - return Ok(()); - }; - match profile.format.as_deref() { - None | Some(PROFILE_FORMAT_V1) => Ok(()), - Some(found) => Err(ProfileError::UnsupportedFormat { - found: found.to_string(), - }), - } - } -} - #[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct ProfileConfig { + #[serde(default)] + slug: Option, + #[serde(default)] + description: Option, + #[serde(default)] + model: Option, + #[serde(default)] + worker: Option, + #[serde(default)] + scope: Option, + #[serde(default)] + session: Option, + #[serde(default)] + permissions: Option, + #[serde(default)] + compaction: Option, + #[serde(default)] + web: Option, + #[serde(default)] + memory: Option, + #[serde(default)] + skills: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +enum ProfileScopeConfig { + Intent { intent: ProfileScopeIntent }, + String(ProfileScopeIntent), +} +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "snake_case")] +enum ProfileScopeIntent { + WorkspaceRead, + WorkspaceWrite, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RatioCompaction { + #[allow(dead_code)] + kind: String, + #[serde(default)] + threshold: Option, + #[serde(default, alias = "request")] + request_threshold: Option, + #[serde(default, alias = "worker")] + worker_context_max_tokens: Option, +} + +#[derive(Debug, Deserialize)] struct ProfileRegistryDocument { #[serde(default)] default: Option, #[serde(default, alias = "entries")] profile: BTreeMap, } - #[derive(Debug, Deserialize)] #[serde(untagged)] enum ProfileEntryConfig { @@ -537,7 +540,6 @@ enum ProfileEntryConfig { description: Option, }, } - impl ProfileEntryConfig { fn into_parts(self) -> (PathBuf, Option) { match self { @@ -565,7 +567,6 @@ fn load_profile_registry_file( source, })?; let base = path.parent().unwrap_or_else(|| Path::new(".")); - for (name, entry_config) in config.profile { let (entry_path, description) = entry_config.into_parts(); registry.push_entry(ProfileRegistryEntry { @@ -576,7 +577,6 @@ fn load_profile_registry_file( is_default: false, }); } - if let Some(default) = config.default { let (default_source, default_name) = parse_profile_ref(&default); registry.set_default(ProfileDefault { @@ -584,13 +584,9 @@ fn load_profile_registry_file( name: default_name, }); } - Ok(()) } -/// Walk up from `start` looking for `.insomnia/profiles.toml`. Returns the -/// closest match, or `None` if none is found before reaching the filesystem -/// root. fn find_project_profiles_from(start: &Path) -> Option { let start = start .canonicalize() @@ -624,7 +620,7 @@ fn discover_profile_dir( source, })?; let path = entry.path(); - if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("nix") { + if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("lua") { if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { registry.push_entry(ProfileRegistryEntry { source, @@ -635,17 +631,17 @@ fn discover_profile_dir( }); } } else if path.is_dir() { - let profile = path.join("profile.nix"); - if profile.is_file() { - if let Some(name) = path.file_name().and_then(|s| s.to_str()) { - registry.push_entry(ProfileRegistryEntry { - source, - name: name.to_string(), - path: profile, - description: None, - is_default: false, - }); - } + let profile = path.join("profile.lua"); + if profile.is_file() + && let Some(name) = path.file_name().and_then(|s| s.to_str()) + { + registry.push_entry(ProfileRegistryEntry { + source, + name: name.to_string(), + path: profile, + description: None, + is_default: false, + }); } } } @@ -653,45 +649,450 @@ fn discover_profile_dir( } fn parse_profile_ref(raw: &str) -> (Option, String) { - if let Some((prefix, name)) = raw.split_once(':') { - if let Some(source) = ProfileRegistrySource::parse(prefix) { - return (Some(source), name.to_string()); - } + if let Some((prefix, name)) = raw.split_once(':') + && let Some(source) = ProfileRegistrySource::parse(prefix) + { + return (Some(source), name.to_string()); } (None, raw.to_string()) } -fn extract_manifest_value(raw: &serde_json::Value) -> Result { - match raw { - serde_json::Value::Object(map) => { - let manifest = map.get("manifest"); - let config = map.get("config"); - match (manifest, config) { - (Some(_), Some(_)) => Err(ProfileError::InvalidArtifact( - "profile artifact must not contain both `manifest` and `config`".to_string(), - )), - (Some(value), None) | (None, Some(value)) => Ok(value.clone()), - (None, None) => Ok(raw.clone()), - } - } - _ => Err(ProfileError::InvalidArtifact( - "profile artifact must be a JSON object".to_string(), +fn evaluate_lua_profile( + path: &Path, + module_root: &Path, +) -> Result { + let content = std::fs::read_to_string(path).map_err(|source| ProfileError::ConfigRead { + path: path.to_path_buf(), + source, + })?; + let lua = Lua::new_with( + StdLib::TABLE | StdLib::STRING | StdLib::MATH | StdLib::UTF8, + LuaOptions::default(), + ) + .map_err(ProfileError::Lua)?; + install_lua_api(&lua, module_root.to_path_buf())?; + let value: LuaValue = lua + .load(&content) + .set_name(path.display().to_string()) + .eval() + .map_err(ProfileError::Lua)?; + match value { + LuaValue::Table(_) => lua.from_value(value).map_err(ProfileError::Lua), + _ => Err(ProfileError::InvalidProfile( + "Lua profile must return a table or profile { ... }".into(), )), } } +fn install_lua_api(lua: &Lua, module_root: PathBuf) -> Result<(), ProfileError> { + let loader = Rc::new(RefCell::new(LocalModuleLoader { + root: module_root, + cache: HashMap::new(), + loading: HashSet::new(), + })); + let require_loader = Rc::clone(&loader); + let require = lua + .create_function(move |lua, name: String| require_module(lua, &require_loader, &name)) + .map_err(ProfileError::Lua)?; + let globals = lua.globals(); + globals.set("require", require).map_err(ProfileError::Lua)?; + globals + .set( + "profile", + lua.create_function(|_, table: Table| Ok(table)) + .map_err(ProfileError::Lua)?, + ) + .map_err(ProfileError::Lua)?; + for denied in [ + "os", + "io", + "debug", + "package", + "dofile", + "loadfile", + "load", + "collectgarbage", + ] { + globals + .set(denied, LuaValue::Nil) + .map_err(ProfileError::Lua)?; + } + Ok(()) +} + +struct LocalModuleLoader { + root: PathBuf, + cache: HashMap, + loading: HashSet, +} + +fn require_module( + lua: &Lua, + loader: &Rc>, + name: &str, +) -> mlua::Result { + if let Some(value) = host_module(lua, name)? { + return Ok(value); + } + if name.starts_with("insomnia.") || name == "insomnia" { + return Err(mlua::Error::RuntimeError(format!( + "unknown host module `{name}`" + ))); + } + validate_module_name(name).map_err(mlua::Error::RuntimeError)?; + if let Some(key) = loader.borrow().cache.get(name) { + return lua.registry_value(key); + } + { + let mut state = loader.borrow_mut(); + if !state.loading.insert(name.to_string()) { + return Err(mlua::Error::RuntimeError(format!( + "cyclic local require `{name}`" + ))); + } + } + let path = local_module_path(&loader.borrow().root, name).map_err(mlua::Error::RuntimeError)?; + let content = std::fs::read_to_string(&path).map_err(|e| { + mlua::Error::RuntimeError(format!( + "failed to read local module `{name}` ({}): {e}", + path.display() + )) + })?; + let result: mlua::Result = lua + .load(&content) + .set_name(path.display().to_string()) + .eval(); + loader.borrow_mut().loading.remove(name); + let value = result?; + let key = lua.create_registry_value(value.clone())?; + loader.borrow_mut().cache.insert(name.to_string(), key); + Ok(value) +} + +fn host_module(lua: &Lua, name: &str) -> mlua::Result> { + match name { + "insomnia" => { + let t = lua.create_table()?; + t.set("profile", profile_function(lua)?)?; + t.set("models", models_module(lua)?)?; + t.set("compact", compact_module(lua)?)?; + t.set("scope", scope_module(lua)?)?; + Ok(Some(LuaValue::Table(t))) + } + "insomnia.profile" => Ok(Some(LuaValue::Function(profile_function(lua)?))), + "insomnia.models" => Ok(Some(LuaValue::Table(models_module(lua)?))), + "insomnia.compact" => Ok(Some(LuaValue::Table(compact_module(lua)?))), + "insomnia.scope" => Ok(Some(LuaValue::Table(scope_module(lua)?))), + _ => Ok(None), + } +} +fn profile_function(lua: &Lua) -> mlua::Result { + lua.create_function(|_, table: Table| Ok(table)) +} +fn models_module(lua: &Lua) -> mlua::Result { + let t = lua.create_table()?; + t.set( + "catalog", + lua.create_function(|lua, reference: String| { + let model = lua.create_table()?; + model.set("ref", reference)?; + Ok(model) + })?, + )?; + Ok(t) +} +fn compact_module(lua: &Lua) -> mlua::Result
{ + let t = lua.create_table()?; + t.set( + "ratio", + lua.create_function(|_, table: Table| { + table.set("kind", "ratio")?; + Ok(table) + })?, + )?; + t.set( + "tokens", + lua.create_function(|_, table: Table| { + table.set("kind", "tokens")?; + Ok(table) + })?, + )?; + Ok(t) +} +fn scope_module(lua: &Lua) -> mlua::Result
{ + let t = lua.create_table()?; + t.set( + "workspace_write", + lua.create_function(|lua, ()| { + let v = lua.create_table()?; + v.set("intent", "workspace_write")?; + Ok(v) + })?, + )?; + t.set( + "workspace_read", + lua.create_function(|lua, ()| { + let v = lua.create_table()?; + v.set("intent", "workspace_read")?; + Ok(v) + })?, + )?; + Ok(t) +} + +fn validate_module_name(name: &str) -> Result<(), String> { + if name.is_empty() { + return Err("empty module name".into()); + } + for part in name.split('.') { + let mut chars = part.chars(); + let Some(first) = chars.next() else { + return Err(format!("invalid local module name `{name}`")); + }; + if !(first == '_' || first.is_ascii_alphabetic()) + || !chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) + { + return Err(format!("invalid local module name `{name}`")); + } + } + Ok(()) +} +fn local_module_path(root: &Path, name: &str) -> Result { + let mut path = root.to_path_buf(); + for part in name.split('.') { + path.push(part); + } + path.set_extension("lua"); + let canonical = path + .canonicalize() + .map_err(|e| format!("local module `{name}` not found: {e}"))?; + if !canonical.starts_with(root) { + return Err(format!("local module `{name}` escapes profile directory")); + } + Ok(canonical) +} + +fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), ProfileError> { + let Some(map) = value.as_object() else { + return Err(ProfileError::InvalidProfile( + "Lua profile must return an object/table".into(), + )); + }; + for key in ["manifest", "config"] { + if map.contains_key(key) { + return Err(ProfileError::InvalidProfile(format!( + "field `{key}` is a complete Manifest artifact boundary and is not allowed in reusable Profiles; use --manifest for complete Manifests" + ))); + } + } + if map.contains_key("pod") { + return Err(ProfileError::InvalidProfile("field `pod` is runtime-bound and is not allowed in reusable Profiles; pass the Pod name via CLI/TUI runtime inputs".into())); + } + if let Some(scope) = map.get("scope").and_then(|v| v.as_object()) { + for key in ["allow", "deny"] { + if scope.contains_key(key) { + return Err(ProfileError::InvalidProfile(format!( + "field `scope.{key}` grants concrete authority and is not allowed in reusable Profiles; use require(\"insomnia.scope\") intent helpers" + ))); + } + } + } + Ok(()) +} + +fn validate_profile_paths(profile: &ProfileConfig) -> Result<(), ProfileError> { + if let Some(model) = &profile.model { + reject_absolute_auth_file(&model.auth, "model.auth.file")?; + } + if let Some(compaction) = &profile.compaction + && let Some(model) = compaction.get("model") + { + let model: ModelManifest = serde_json::from_value(model.clone()) + .map_err(|source| ProfileError::ProfileDeserialize { source })?; + reject_absolute_auth_file(&model.auth, "compaction.model.auth.file")?; + } + if let Some(memory) = &profile.memory + && let Some(root) = &memory.workspace_root + && root.is_absolute() + { + return Err(ProfileError::InvalidProfile("field `memory.workspace_root` is a resolved path and is not allowed in reusable Profiles".into())); + } + if let Some(skills) = &profile.skills { + for dir in &skills.directories { + if dir.is_absolute() { + return Err(ProfileError::InvalidProfile( + "field `skills.directories` must be profile-relative in reusable Profiles" + .into(), + )); + } + } + } + Ok(()) +} +fn reject_absolute_auth_file( + auth: &Option, + field: &'static str, +) -> Result<(), ProfileError> { + if let Some(AuthRef::ApiKey { + file: Some(file), .. + }) = auth + && file.is_absolute() + { + return Err(ProfileError::InvalidProfile(format!( + "field `{field}` is a resolved path and is not allowed in reusable Profiles" + ))); + } + Ok(()) +} +fn profile_scope_to_config( + scope: Option, + workspace_base: &Path, +) -> ScopeConfig { + let intent = match scope { + Some(ProfileScopeConfig::Intent { intent }) | Some(ProfileScopeConfig::String(intent)) => { + intent + } + None => ProfileScopeIntent::WorkspaceWrite, + }; + let permission = match intent { + ProfileScopeIntent::WorkspaceRead => Permission::Read, + ProfileScopeIntent::WorkspaceWrite => Permission::Write, + }; + ScopeConfig { + allow: vec![ScopeRule { + target: workspace_base.to_path_buf(), + permission, + recursive: true, + }], + deny: Vec::new(), + } +} +fn profile_compaction_to_partial( + value: Option, + model: &Option, +) -> Result, ProfileError> { + let Some(value) = value else { + return Ok(None); + }; + let Some(kind) = value.get("kind").and_then(|v| v.as_str()) else { + return serde_json::from_value(value) + .map(Some) + .map_err(|source| ProfileError::ProfileDeserialize { source }); + }; + match kind { + "tokens" => { + let mut obj = value.as_object().cloned().unwrap_or_default(); + obj.remove("kind"); + serde_json::from_value(serde_json::Value::Object(obj)) + .map(Some) + .map_err(|source| ProfileError::ProfileDeserialize { source }) + } + "ratio" => { + let ratio: RatioCompaction = serde_json::from_value(value) + .map_err(|source| ProfileError::ProfileDeserialize { source })?; + let context = model_context_window(model.as_ref()).ok_or_else(|| ProfileError::InvalidProfile("compact.ratio requires model.context_window/max_context_window or a known model ref; use compact.tokens for explicit token values".into()))?; + Ok(Some(CompactionConfigPartial { + threshold: ratio.threshold.map(|r| ratio_tokens(context, r)), + request_threshold: ratio.request_threshold.map(|r| ratio_tokens(context, r)), + worker_context_max_tokens: ratio + .worker_context_max_tokens + .map(|r| ratio_tokens(context, r)), + ..Default::default() + })) + } + other => Err(ProfileError::InvalidProfile(format!( + "unknown compaction helper kind `{other}`" + ))), + } +} +fn ratio_tokens(context: u64, ratio: f64) -> u64 { + ((context as f64) * ratio).floor() as u64 +} +fn model_context_window(model: Option<&ModelManifest>) -> Option { + let model = model?; + if let Some(max) = model.max_context_window { + return Some(model.context_window.map_or(max, |ctx| ctx.min(max))); + } + if let Some(ctx) = model.context_window { + return Some(ctx); + } + builtin_model_context_window(model.ref_.as_deref()?) +} +fn builtin_model_context_window(reference: &str) -> Option { + let (provider, model_id) = reference.split_once('/')?; + let path = paths::resource_dir()?.join("models").join("builtin.toml"); + let content = std::fs::read_to_string(path).ok()?; + let parsed: toml::Value = toml::from_str(&content).ok()?; + for entry in parsed.get("model")?.as_array()? { + let table = entry.as_table()?; + if table.get("provider")?.as_str()? == provider && table.get("id")?.as_str()? == model_id { + let context = table.get("context_window")?.as_integer()? as u64; + let max = table + .get("max_context_window") + .and_then(|v| v.as_integer()) + .map(|v| v as u64); + return Some(max.map_or(context, |max| context.min(max))); + } + } + None +} +fn derive_pod_name(source: &ProfileSource, slug: Option<&str>) -> String { + if matches!(source, ProfileSource::Registry { source: ProfileRegistrySource::Builtin, name, .. } if name == BUILTIN_DEFAULT_PROFILE_NAME) + || slug == Some(BUILTIN_DEFAULT_PROFILE_NAME) + { + return DEFAULT_POD_NAME.to_string(); + } + let raw = slug + .map(str::to_string) + .or_else(|| source_name(source)) + .unwrap_or_else(|| DEFAULT_POD_NAME.to_string()); + sanitise_pod_name(&raw) +} +fn source_name(source: &ProfileSource) -> Option { + match source { + ProfileSource::Path { path } => path + .file_stem() + .and_then(|s| s.to_str()) + .map(str::to_string), + ProfileSource::Registry { name, .. } => Some(name.clone()), + } +} +fn sanitise_pod_name(raw: &str) -> String { + let name: String = raw + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '-' + } + }) + .collect(); + if name.is_empty() { + DEFAULT_POD_NAME.to_string() + } else { + name + } +} +fn canonicalize_existing_dir(path: &Path) -> Result { + path.canonicalize() + .map_err(|source| ProfileError::CommandIo { + path: path.to_path_buf(), + source, + }) +} fn absolutize(path: &Path) -> Result { if path.is_absolute() { Ok(path.to_path_buf()) } else { - let cwd = std::env::current_dir().map_err(|source| ProfileError::CommandIo { - path: PathBuf::from("."), - source, - })?; - Ok(cwd.join(path)) + Ok(std::env::current_dir() + .map_err(|source| ProfileError::CommandIo { + path: PathBuf::from("."), + source, + })? + .join(path)) } } - fn join_if_relative(base: &Path, path: &Path) -> PathBuf { if path.is_absolute() { path.to_path_buf() @@ -700,80 +1101,62 @@ fn join_if_relative(base: &Path, path: &Path) -> PathBuf { } } -/// Errors raised while evaluating, discovering, and validating a profile. +pub fn resolve_profile_artifact( + source: ProfileSource, + base_dir: &Path, + raw_artifact: serde_json::Value, +) -> Result { + resolve_lua_profile_value( + source, + base_dir, + base_dir, + ProfileResolveOptions::default(), + raw_artifact.clone(), + raw_artifact, + ) +} + #[derive(Debug, thiserror::Error)] pub enum ProfileError { #[error("invalid profile path {}: {message}", .path.display())] InvalidPath { path: PathBuf, message: String }, - - #[error("Nix profile resolution requires the `nix` command ({}) but it was not found while resolving {}; install Nix or use --manifest with a resolved TOML manifest", .nix_bin.display(), .profile.display())] - NixUnavailable { nix_bin: PathBuf, profile: PathBuf }, - - #[error("failed to execute nix for profile {}: {source}", .path.display())] + #[error("unsupported profile type {}: {message}", .path.display())] + UnsupportedProfileType { path: PathBuf, message: String }, + #[error("failed to access profile path {}: {source}", .path.display())] CommandIo { path: PathBuf, #[source] source: std::io::Error, }, - - #[error("nix eval failed for profile {} (status {}): {stderr}", .path.display(), status.map_or_else(|| "signal".to_string(), |s| s.to_string()))] - NixFailed { - path: PathBuf, - status: Option, - stderr: String, - }, - - #[error("nix eval did not produce valid JSON for profile {}: {source}", .path.display())] - JsonParse { - path: PathBuf, - #[source] - source: serde_json::Error, - }, - #[error("failed to read profile registry config {}: {source}", .path.display())] ConfigRead { path: PathBuf, #[source] source: std::io::Error, }, - #[error("failed to parse profile registry config {}: {source}", .path.display())] ConfigParse { path: PathBuf, #[source] source: toml::de::Error, }, - #[error("no default profile is configured")] NoDefaultProfile, - #[error("profile not found: {selector}")] ProfileNotFound { selector: String }, - #[error("ambiguous profile name `{name}`; use a source-qualified selector such as {matches:?}")] AmbiguousProfileName { name: String, matches: Vec }, - - #[error("failed to decode profile artifact envelope: {source}")] - ArtifactShape { + #[error("failed to evaluate Lua profile: {0}")] + Lua(#[source] mlua::Error), + #[error("invalid Lua profile: {0}")] + InvalidProfile(String), + #[error("failed to decode Profile: {source}")] + ProfileDeserialize { #[source] source: serde_json::Error, }, - - #[error("unsupported profile artifact format: {found}")] - UnsupportedFormat { found: String }, - - #[error("invalid profile artifact: {0}")] - InvalidArtifact(String), - - #[error("failed to decode profile manifest/config: {source}")] - ManifestDeserialize { - #[source] - source: serde_json::Error, - }, - - #[error("failed to resolve profile manifest/config: {0}")] + #[error("failed to resolve Profile into Manifest: {0}")] ManifestResolve(#[source] ResolveError), - #[error("failed to serialize resolved manifest snapshot: {0}")] SnapshotSerialize(#[source] serde_json::Error), } @@ -781,7 +1164,7 @@ pub enum ProfileError { #[cfg(test)] mod tests { use super::*; - use crate::{AuthRef, Permission, SchemeKind}; + use crate::{ReasoningControl, ReasoningEffort, SchemeKind}; use std::sync::{Mutex, MutexGuard, OnceLock}; use tempfile::TempDir; @@ -791,24 +1174,22 @@ mod tests { .lock() .unwrap_or_else(|e| e.into_inner()) } - struct EnvGuard { vars: Vec<(&'static str, Option)>, _lock: MutexGuard<'static, ()>, } - impl EnvGuard { fn new(overrides: &[(&'static str, Option<&str>)]) -> Self { let lock = env_lock(); let names = [ "INSOMNIA_CONFIG_DIR", "INSOMNIA_USER_MANIFEST", + "INSOMNIA_RESOURCE_DIR", "INSOMNIA_HOME", "XDG_CONFIG_HOME", "HOME", ]; let saved: Vec<_> = names.iter().map(|n| (*n, std::env::var(n).ok())).collect(); - // SAFETY: env_lock() protects environment mutation within this test binary. unsafe { for (n, _) in &saved { std::env::remove_var(n); @@ -825,10 +1206,8 @@ mod tests { } } } - impl Drop for EnvGuard { fn drop(&mut self) { - // SAFETY: env_lock() is still held while restoring the environment. unsafe { for (n, v) in &self.vars { match v { @@ -839,157 +1218,20 @@ mod tests { } } } - - fn artifact() -> serde_json::Value { - serde_json::json!({ - "profile": { - "format": "insomnia.nix-profile.v1", - "name": "coder", - "description": "Coder profile" - }, - "manifest": { - "pod": { "name": "coder-pod" }, - "model": { - "scheme": "anthropic", - "model_id": "claude-sonnet-4-20250514", - "auth": { "kind": "secret_ref", "ref": "llm.anthropic.default" } - }, - "scope": { - "allow": [ - { "target": ".", "permission": "write" } - ] - } - } - }) - } - - #[test] - fn resolves_profile_artifact_with_relative_paths() { - let resolved = resolve_profile_artifact( - ProfileSource::Path { - path: PathBuf::from("/profiles/coder.nix"), - }, - Path::new("/workspace/project"), - artifact(), - ) - .unwrap(); - - assert_eq!( - resolved.profile.as_ref().unwrap().name.as_deref(), - Some("coder") - ); - assert_eq!(resolved.manifest.pod.name, "coder-pod"); - assert_eq!(resolved.manifest.model.scheme, Some(SchemeKind::Anthropic)); - assert_eq!( - resolved.manifest.scope.allow[0].target, - PathBuf::from("/workspace/project") - ); - assert_eq!( - resolved.manifest.scope.allow[0].permission, - Permission::Write - ); - assert!(matches!( - resolved.manifest.model.auth, - Some(AuthRef::SecretRef { ref_ }) if ref_ == "llm.anthropic.default" - )); - assert_eq!( - resolved.manifest_snapshot["model"]["auth"], - serde_json::json!({ "kind": "secret_ref", "ref": "llm.anthropic.default" }) - ); - } - - #[test] - fn profile_artifact_preserves_session_record_event_trace() { - let mut raw = artifact(); - raw["manifest"]["session"] = serde_json::json!({ "record_event_trace": true }); - - let resolved = resolve_profile_artifact( - ProfileSource::Path { - path: PathBuf::from("/profiles/coder.nix"), - }, - Path::new("/workspace/project"), - raw, - ) - .unwrap(); - - assert!(resolved.manifest.session.record_event_trace); - assert_eq!( - resolved.manifest_snapshot["session"]["record_event_trace"], - serde_json::json!(true) - ); - } - - #[test] - fn rejects_both_manifest_and_config_fields() { - let err = resolve_profile_artifact( - ProfileSource::Path { - path: PathBuf::from("/profiles/bad.nix"), - }, - Path::new("/workspace/project"), - serde_json::json!({ "manifest": {}, "config": {} }), - ) - .unwrap_err(); - - assert!(matches!(err, ProfileError::InvalidArtifact(_))); - } - - #[test] - fn accepts_raw_manifest_object_for_debug_paths() { - let raw = serde_json::json!({ - "pod": { "name": "raw" }, - "model": { "scheme": "anthropic", "model_id": "claude-sonnet-4-20250514" }, - "scope": { "allow": [{ "target": "/tmp/raw", "permission": "read" }] } - }); - - let resolved = resolve_profile_artifact( - ProfileSource::Path { - path: PathBuf::from("/profiles/raw.nix"), - }, - Path::new("/profiles"), - raw, - ) - .unwrap(); - - assert_eq!(resolved.manifest.pod.name, "raw"); - assert_eq!( - resolved.manifest.scope.allow[0].target, - PathBuf::from("/tmp/raw") - ); - } - - #[test] - fn rejects_unknown_profile_format() { - let mut raw = artifact(); - raw["profile"]["format"] = serde_json::json!("insomnia.nix-profile.v99"); - - let err = resolve_profile_artifact( - ProfileSource::Path { - path: PathBuf::from("/profiles/coder.nix"), - }, - Path::new("/workspace/project"), - raw, - ) - .unwrap_err(); - - assert!(matches!(err, ProfileError::UnsupportedFormat { .. })); - } - - #[test] - fn missing_nix_has_clear_diagnostic() { - let resolver = NixProfileResolver::with_nix_bin("/definitely/missing/nix"); - let err = resolver - .resolve(&ProfileSelector::path("/profiles/coder.nix")) - .unwrap_err(); - - assert!(matches!(err, ProfileError::NixUnavailable { .. })); - assert!(err.to_string().contains("requires the `nix` command")); - assert!(err.to_string().contains("--manifest")); + fn write_profile(dir: &Path, name: &str, body: &str) -> PathBuf { + let path = dir.join(name); + std::fs::write(&path, body).unwrap(); + path } #[test] fn parse_cli_preserves_paths_and_source_qualified_names() { assert!(matches!( - ProfileSelector::parse_cli("./coder.nix"), + ProfileSelector::parse_cli("./coder.lua"), + ProfileSelector::Path { .. } + )); + assert!(matches!( + ProfileSelector::parse_cli("./legacy.nix"), ProfileSelector::Path { .. } )); assert_eq!( @@ -1005,7 +1247,6 @@ mod tests { ProfileSelector::Default ); } - #[test] fn builtin_default_profile_is_registered_as_default() { let registry = ProfileDiscovery::with_sources(paths::builtin_profiles_dir(), None, None) @@ -1015,85 +1256,232 @@ mod tests { 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")); + assert!(default.path.ends_with("resources/profiles/default.lua")); } - - #[cfg(unix)] #[test] - fn builtin_profile_relative_paths_resolve_against_workspace_base() { - use std::os::unix::fs::PermissionsExt; - + fn resolves_plain_lua_profile_with_runtime_pod_name_and_scope_intent() { 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 + let profile = write_profile( + tmp.path(), + "coder.lua", + r#" +local profile = require("insomnia.profile") +local scope = require("insomnia.scope") +return profile { + slug = "coder", + model = { scheme = "anthropic", model_id = "claude-sonnet-4-20250514" }, + worker = { reasoning = "high" }, + scope = scope.workspace_read(), +} "#, - ) - .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()), + ); + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace).unwrap(); + let resolved = ProfileResolver::new() + .with_workspace_base(&workspace) + .resolve( + &ProfileSelector::path(&profile), + ProfileResolveOptions::with_pod_name("runtime-pod"), ) .unwrap(); - + assert_eq!(resolved.manifest.pod.name, "runtime-pod"); + assert_eq!(resolved.manifest.model.scheme, Some(SchemeKind::Anthropic)); + assert_eq!( + resolved.manifest.worker.reasoning, + Some(ReasoningControl::Effort(ReasoningEffort::High)) + ); assert_eq!(resolved.manifest.scope.allow[0].target, workspace); + assert_eq!( + resolved.manifest.scope.allow[0].permission, + Permission::Read + ); + assert_eq!( + resolved.profile.as_ref().unwrap().name.as_deref(), + Some("coder") + ); + } + #[test] + fn host_modules_and_local_require_work() { + let tmp = TempDir::new().unwrap(); + std::fs::write( + tmp.path().join("shared.lua"), + r#"return { model = require("insomnia.models").catalog("codex-oauth/gpt-5.5") }"#, + ) + .unwrap(); + let profile = write_profile( + tmp.path(), + "main.lua", + r#" +local insomnia = require("insomnia") +local shared = require("shared") +return insomnia.profile { + slug = "main", + model = shared.model, + scope = insomnia.scope.workspace_write(), +} +"#, + ); + let resolved = ProfileResolver::new() + .with_workspace_base(tmp.path()) + .resolve( + &ProfileSelector::path(profile), + ProfileResolveOptions::with_pod_name("p"), + ) + .unwrap(); + assert_eq!( + resolved.manifest.model.ref_.as_deref(), + Some("codex-oauth/gpt-5.5") + ); + assert_eq!( + resolved.manifest.scope.allow[0].permission, + Permission::Write + ); + } + #[test] + fn sandbox_denies_unsafe_libraries() { + let tmp = TempDir::new().unwrap(); + for (name, body) in [ + ("os.lua", "return os.getenv('HOME')"), + ("io.lua", "return io.open('x')"), + ("debug.lua", "return debug.getinfo(1)"), + ("package.lua", "return package.path"), + ] { + let path = write_profile(tmp.path(), name, body); + let err = ProfileResolver::new() + .with_workspace_base(tmp.path()) + .resolve( + &ProfileSelector::path(path), + ProfileResolveOptions::with_pod_name("p"), + ) + .unwrap_err(); + assert!(matches!( + err, + ProfileError::Lua(_) | ProfileError::InvalidProfile(_) + )); + } + } + #[test] + fn rejects_manifest_shaped_runtime_and_authority_fields() { + for (value, needle) in [ + (serde_json::json!({"manifest": {}}), "manifest"), + (serde_json::json!({"config": {}}), "config"), + (serde_json::json!({"pod": {"name": "bad"}}), "pod"), + ( + serde_json::json!({"model": {"ref": "codex-oauth/gpt-5.5"}, "scope": {"allow": []}}), + "scope.allow", + ), + ( + serde_json::json!({"model": {"ref": "codex-oauth/gpt-5.5"}, "scope": {"deny": []}}), + "scope.deny", + ), + ] { + let err = resolve_profile_artifact( + ProfileSource::Path { + path: PathBuf::from("/profiles/bad.lua"), + }, + Path::new("/workspace"), + value, + ) + .unwrap_err(); + assert!(err.to_string().contains(needle), "unexpected error: {err}"); + } + } + #[test] + fn rejects_absolute_profile_paths() { + let err = resolve_profile_artifact(ProfileSource::Path { path: PathBuf::from("/profiles/bad.lua") }, Path::new("/workspace"), serde_json::json!({"model": { "scheme": "anthropic", "model_id": "m", "auth": {"kind":"api_key", "file":"/secret/key"} }, "scope": "workspace_write"})).unwrap_err(); + assert!(err.to_string().contains("model.auth.file")); + } + #[test] + fn compact_ratio_uses_known_model_context() { + let tmp = TempDir::new().unwrap(); + let profile = write_profile( + tmp.path(), + "ratio.lua", + r#" +local profile = require("insomnia.profile") +local models = require("insomnia.models") +local compact = require("insomnia.compact") +return profile { + model = models.catalog("codex-oauth/gpt-5.5"), + compaction = compact.ratio { threshold = 0.5, request = 0.75, worker = 0.25 }, +} +"#, + ); + let resolved = ProfileResolver::new() + .with_workspace_base(tmp.path()) + .resolve( + &ProfileSelector::path(profile), + ProfileResolveOptions::with_pod_name("p"), + ) + .unwrap(); + let c = resolved.manifest.compaction.unwrap(); + assert_eq!(c.threshold, Some(136000)); + assert_eq!(c.request_threshold, Some(204000)); + assert_eq!(c.worker_context_max_tokens, 68000); + } + #[test] + fn builtin_default_resolves_without_external_evaluator() { + let tmp = TempDir::new().unwrap(); + let resolved = ProfileResolver::new() + .with_workspace_base(tmp.path()) + .resolve(&ProfileSelector::Default, ProfileResolveOptions::default()) + .unwrap(); + assert_eq!(resolved.manifest.pod.name, "insomnia"); + assert_eq!( + resolved.manifest.model.ref_.as_deref(), + Some("codex-oauth/gpt-5.5") + ); + assert_eq!(resolved.manifest.scope.allow[0].target, tmp.path()); + assert_eq!( + resolved.manifest.scope.allow[0].permission, + Permission::Write + ); + assert!(resolved.manifest.session.record_event_trace); + assert_eq!( + resolved.profile.as_ref().unwrap().name.as_deref(), + Some("default") + ); + } + #[test] + fn unsupported_nix_profile_has_clear_diagnostic() { + let tmp = TempDir::new().unwrap(); + let path = write_profile(tmp.path(), "legacy.nix", "{}"); + let err = ProfileResolver::new() + .with_workspace_base(tmp.path()) + .resolve( + &ProfileSelector::path(path), + ProfileResolveOptions::default(), + ) + .unwrap_err(); + assert!(matches!(err, ProfileError::UnsupportedProfileType { .. })); + assert!(err.to_string().contains("--manifest")); } - #[test] fn for_cwd_reads_profiles_toml_and_ignores_manifest_profiles() { let tmp = TempDir::new().unwrap(); let config_dir = tmp.path().join("config"); std::fs::create_dir_all(&config_dir).unwrap(); let _env = EnvGuard::new(&[("INSOMNIA_CONFIG_DIR", Some(config_dir.to_str().unwrap()))]); - let project = tmp.path().join("project").join("nested"); let insomnia = tmp.path().join("project").join(".insomnia"); std::fs::create_dir_all(&project).unwrap(); std::fs::create_dir_all(&insomnia).unwrap(); std::fs::write( insomnia.join("manifest.toml"), - r#" -[profiles] -default = "wrong" -[profiles.profile] -wrong = "wrong.nix" -"#, + "[profiles]\ndefault = \"wrong\"\n[profiles.profile]\nwrong = \"wrong.lua\"\n", ) .unwrap(); std::fs::write( insomnia.join("profiles.toml"), - r#" -default = "coder" -[profile] -coder = "profiles/coder.nix" -"#, + "default = \"coder\"\n[profile]\ncoder = \"profiles/coder.lua\"\n", ) .unwrap(); - let registry = ProfileDiscovery::for_cwd(&project).discover().unwrap(); assert!(registry.select_named(None, "wrong").is_err()); let selected = registry.default_entry().unwrap(); assert_eq!(selected.source, ProfileRegistrySource::Project); assert_eq!(selected.name, "coder"); } - #[test] fn user_manifest_env_does_not_affect_profile_registry_discovery() { let tmp = TempDir::new().unwrap(); @@ -1102,21 +1490,12 @@ coder = "profiles/coder.nix" let env_manifest = tmp.path().join("env-manifest.toml"); std::fs::write( &env_manifest, - r#" -[profiles] -default = "wrong" -[profiles.profile] -wrong = "wrong.nix" -"#, + "[profiles]\ndefault = \"wrong\"\n[profiles.profile]\nwrong = \"wrong.lua\"\n", ) .unwrap(); std::fs::write( config_dir.join("profiles.toml"), - r#" -default = "coder" -[profile] -coder = "profiles/coder.nix" -"#, + "default = \"coder\"\n[profile]\ncoder = \"profiles/coder.lua\"\n", ) .unwrap(); let _env = EnvGuard::new(&[ @@ -1126,14 +1505,12 @@ coder = "profiles/coder.nix" Some(env_manifest.to_str().unwrap()), ), ]); - let registry = ProfileDiscovery::for_cwd(tmp.path()).discover().unwrap(); assert!(registry.select_named(None, "wrong").is_err()); let selected = registry.default_entry().unwrap(); assert_eq!(selected.source, ProfileRegistrySource::User); assert_eq!(selected.name, "coder"); } - #[test] fn discovery_reads_user_and_project_registry_and_project_default_wins() { let tmp = TempDir::new().unwrap(); @@ -1143,35 +1520,19 @@ coder = "profiles/coder.nix" let project_config = project_dir.join("profiles.toml"); std::fs::write( &user_config, - r#" -default = "coder" -[profile] -coder = "profiles/user-coder.nix" -"#, + "default = \"coder\"\n[profile]\ncoder = \"profiles/user-coder.lua\"\n", ) .unwrap(); - std::fs::write( - &project_config, - r#" -default = "project:coder" -[profile.coder] -path = "profiles/project-coder.nix" -description = "Project coder" -"#, - ) - .unwrap(); - + std::fs::write(&project_config, "default = \"project:coder\"\n[profile.coder]\npath = \"profiles/project-coder.lua\"\ndescription = \"Project coder\"\n").unwrap(); let registry = ProfileDiscovery::with_sources(None, Some(user_config), Some(project_config)) .discover() .unwrap(); - let default = registry.default_entry().unwrap(); assert_eq!(default.source, ProfileRegistrySource::Project); assert_eq!(default.name, "coder"); - assert!(default.path.ends_with("profiles/project-coder.nix")); + assert!(default.path.ends_with("profiles/project-coder.lua")); } - #[test] fn default_marks_direct_profile_entry() { let tmp = TempDir::new().unwrap(); @@ -1180,14 +1541,9 @@ description = "Project coder" let project_config = project_dir.join("profiles.toml"); std::fs::write( &project_config, - r#" -default = "coder" -[profile] -coder = "profiles/coder.nix" -"#, + "default = \"coder\"\n[profile]\ncoder = \"profiles/coder.lua\"\n", ) .unwrap(); - let registry = ProfileDiscovery::with_sources(None, None, Some(project_config)) .discover() .unwrap(); @@ -1204,25 +1560,23 @@ coder = "profiles/coder.nix" 1 ); } - #[test] fn unqualified_ambiguous_names_fail_closed() { let mut registry = ProfileRegistry::default(); registry.push_entry(ProfileRegistryEntry { source: ProfileRegistrySource::User, name: "coder".to_string(), - path: PathBuf::from("/user/coder.nix"), + path: PathBuf::from("/user/coder.lua"), description: None, is_default: false, }); registry.push_entry(ProfileRegistryEntry { source: ProfileRegistrySource::Project, name: "coder".to_string(), - path: PathBuf::from("/project/coder.nix"), + path: PathBuf::from("/project/coder.lua"), description: None, is_default: false, }); - let err = registry .select(&ProfileSelector::named("coder")) .unwrap_err(); @@ -1233,6 +1587,6 @@ coder = "profiles/coder.nix" "coder", )) .unwrap(); - assert_eq!(selected.path, PathBuf::from("/project/coder.nix")); + assert_eq!(selected.path, PathBuf::from("/project/coder.lua")); } } diff --git a/crates/pod/src/main.rs b/crates/pod/src/main.rs index 9b9e6a06..ac316a1d 100644 --- a/crates/pod/src/main.rs +++ b/crates/pod/src/main.rs @@ -2,7 +2,9 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; use clap::Parser; -use manifest::{NixProfileResolver, PodManifest, PodManifestConfig, ProfileSelector, paths}; +use manifest::{ + PodManifest, PodManifestConfig, ProfileResolveOptions, ProfileResolver, ProfileSelector, paths, +}; use pod::{Pod, PodController, PromptLoader}; use pod_store::{CombinedStore, FsPodStore, PodMetadataStore}; use session_store::{FsStore, SegmentId, Store}; @@ -10,10 +12,10 @@ use session_store::{FsStore, SegmentId, Store}; #[derive(Debug, Parser)] #[command( name = "insomnia-pod", - about = "Spawn a Pod process from a Nix profile or a single manifest file" + about = "Spawn a Pod process from a profile or a single manifest file" )] struct Cli { - /// Nix profile to evaluate. Accepts an explicit path, `path:`, a + /// Profile to evaluate. Accepts an explicit path, `path:`, a /// discovered profile name, `default`, or a source-qualified name such as /// `project:coder`. #[arg( @@ -145,16 +147,16 @@ fn load_profile( ) -> Result<(PodManifest, PromptLoader), String> { 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| { + let resolver = ProfileResolver::new().with_workspace_base(cwd); + let options = pod_name_override + .map(ProfileResolveOptions::with_pod_name) + .unwrap_or_default(); + let resolved = resolver.resolve(selector, options).map_err(|e| { format!( "failed to resolve profile {}: {e}", selector.display_label() ) })?; - if let Some(pod_name) = pod_name_override { - resolved.manifest.pod.name = pod_name.to_string(); - } Ok((resolved.manifest, PromptLoader::builtins_only())) } diff --git a/crates/tui/src/spawn.rs b/crates/tui/src/spawn.rs index 3988fdcb..fd0937d3 100644 --- a/crates/tui/src/spawn.rs +++ b/crates/tui/src/spawn.rs @@ -415,7 +415,7 @@ struct Form { /// When true, launch the child with `--pod ` so the pod process /// resolves name-keyed state before falling back to fresh creation. resume_by_pod_name: bool, - /// Optional Nix profile choices passed to `insomnia-pod --profile` for + /// Optional profile choices passed to `insomnia-pod --profile` for /// fresh spawns. This is not used for resume/attach flows because those must /// restore Pod state rather than re-evaluate a profile source. profile_choices: Vec, diff --git a/docs/architecture.md b/docs/architecture.md index 98547255..1c7b483d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -91,7 +91,7 @@ permission = "write" ### Manifest / profile 入力 -通常の 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 は通常起動では使わない。 +通常の Pod 起動は Lua profile discovery/default から `PodManifest` を生成する。bundled `builtin:default` が fallback default で、user/project `profiles.toml` は profile registry と default selection だけを担う。user/project `manifest.toml` の ambient cascade は通常起動では使わない。 `insomnia-pod --manifest ` は explicit one-file compatibility/debug input で、指定 TOML 1 枚だけに builtin defaults を merge し、`PodManifestConfig -> PodManifest` の required validation を通す。 diff --git a/docs/manifest-profiles.md b/docs/manifest-profiles.md index 7a748067..098d0b95 100644 --- a/docs/manifest-profiles.md +++ b/docs/manifest-profiles.md @@ -1,47 +1,55 @@ # Manifest profiles -Manifest profiles are the human-authored Nix entrypoint for generating an Insomnia runtime manifest. The Rust side evaluates a selected profile with `nix eval --json --file `, deserializes the resulting JSON artifact, and validates it through the existing `PodManifest` pipeline. +Profiles are reusable Lua-authored recipes for generating an Insomnia runtime manifest. The Rust resolver evaluates a selected `.lua` profile in-process, validates that it is Profile-shaped rather than a complete Manifest, then binds runtime values such as Pod name and concrete scope to produce the persisted `PodManifest` snapshot. -This keeps composition/import/common logic in Nix. Insomnia does not add an implicit profile cascade or merge TOML profile layers into the selected runtime manifest. +Profiles are intentionally not authority-bearing manifests. `pod.name`, concrete `scope.allow` / `scope.deny`, runtime directories, sockets, active session state, and raw secret material do not belong in reusable profiles. Use `--manifest` when you need the explicit low-level complete Manifest escape hatch. ## Minimal profile -```nix -let - insomnia = import ./resources/nix/profile-lib.nix {}; -in -insomnia.mkProfile { - name = "coder"; - description = "Example coding Pod"; - manifest = insomnia.mkManifest { - pod.name = "coder"; - model = { - scheme = "anthropic"; - model_id = "claude-sonnet-4-20250514"; - auth = insomnia.secrets.ref "llm.anthropic.default"; - }; - scope.allow = [ - { target = "."; permission = "write"; } - ]; - }; +```lua +local profile = require("insomnia.profile") +local models = require("insomnia.models") +local scope = require("insomnia.scope") + +return profile { + slug = "coder", + description = "Example coding Pod", + + model = models.catalog("codex-oauth/gpt-5.5"), + worker = { + reasoning = "high", + }, + scope = scope.workspace_write(), } ``` Run an explicit path with: ```sh -insomnia-pod --profile ./coder.nix +insomnia-pod --profile ./coder.lua # or through the TUI fresh-spawn dialog -insomnia --profile ./coder.nix +insomnia --profile ./coder.lua ``` -`--profile` accepts an explicit path, `path:`, a discovered profile name, `default`, or a source-qualified name such as `project:coder`, `user:coder`, or `builtin:coder`. Path-like values containing `/`, starting with `.`, or ending in `.nix` preserve the original explicit-path behavior. +`--profile` accepts an explicit path, `path:`, a discovered profile name, `default`, or a source-qualified name such as `project:coder`, `user:coder`, or `builtin:coder`. Path-like values containing `/`, starting with `.`, or ending in `.lua` are explicit paths. `.nix` paths are no longer the primary profile layer and fail with a diagnostic that points users at Lua profiles or `--manifest`. -`--profile` conflicts with `insomnia-pod --manifest` and with restore/session/adopt modes. Use `--profile-pod-name ` when a launcher needs a creation-time Pod name override without invoking `--pod` restore semantics. Profile evaluation is a creation-time path; Pod resume restores saved Pod state/resolved snapshots rather than re-evaluating the Nix source. +`--profile` conflicts with `insomnia-pod --manifest` and with restore/session/adopt modes. Use `--profile-pod-name ` when a launcher needs a creation-time Pod name override without invoking `--pod` restore semantics. Profile evaluation is a creation-time path; Pod resume restores saved Pod state/resolved snapshots rather than re-evaluating the profile source. + +## Controlled Lua environment + +Profiles run in a restricted Lua VM. Host virtual modules are available through controlled `require`: + +- `require("insomnia")` +- `require("insomnia.profile")` +- `require("insomnia.models")` +- `require("insomnia.compact")` +- `require("insomnia.scope")` + +Profile-local modules may be required by dotted names such as `require("shared")` or `require("shared.models")`; those resolve only under the selected profile file's directory. Unsafe/unrestricted Lua facilities such as `os`, `io`, `debug`, `package`, `dofile`, and `loadfile` are unavailable by default. ## Profile discovery -Profile discovery is separate from runtime manifest merging. User/project `profiles.toml` files may declare profile registry metadata, but those files are application/project UX configuration and are not merged into the Nix profile artifact. +Profile discovery is separate from runtime manifest merging. User/project `profiles.toml` files may declare profile registry metadata, but those files are application/project UX configuration and are not merged into the selected profile artifact. Example project config at `.insomnia/profiles.toml`: @@ -49,15 +57,15 @@ Example project config at `.insomnia/profiles.toml`: default = "coder" [profile] -coder = "profiles/coder.nix" -reviewer = "profiles/reviewer.nix" +coder = "profiles/coder.lua" +reviewer = "profiles/reviewer.lua" ``` Table entries can carry descriptions: ```toml [profile.coder] -path = "profiles/coder.nix" +path = "profiles/coder.lua" description = "Project coding assistant" ``` @@ -77,22 +85,8 @@ The fresh-spawn TUI also uses discovery. The new Pod dialog defaults to the sele 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 +## Resolver contract -A profile should evaluate to one of: - -- `{ profile = { format = "insomnia.nix-profile.v1"; ... }; manifest = { ... }; }` -- `{ profile = { format = "insomnia.nix-profile.v1"; ... }; config = { ... }; }` -- a raw manifest/config object for debug/test paths. - -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`. +A Lua profile should return either `profile { ... }` or a plain table containing Profile fields. The resolver converts reusable fields such as `model`, `worker`, `compaction`, `memory`, `web`, `permissions`, `session`, and scope intent into a concrete Manifest. Runtime Pod name and concrete scope authority are supplied by launch context, then the resolved Manifest snapshot is persisted for restore. 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 -{ "kind": "secret_ref", "ref": "llm.anthropic.default" } -``` - -The encrypted secret store is intentionally not implemented by this profile foundation; attempting to use a `secret_ref` as a live provider credential currently fails with a clear diagnostic at provider construction time. diff --git a/docs/pod-factory.md b/docs/pod-factory.md index ba24048e..1915806d 100644 --- a/docs/pod-factory.md +++ b/docs/pod-factory.md @@ -345,8 +345,8 @@ 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 用に上書き | +| `--profile ` | builtin/user/project profile registry から Lua profile を選択。省略時は registry default(通常は `builtin:default`) | +| `--profile-pod-name ` | profile resolution 時に fresh spawn 用の runtime `pod.name` を指定 | | `-s, --store ` | セッション永続化ディレクトリ(デフォルト: `/sessions/`、`manifest::paths` で解決) | | `--session ` | 既存 session id から Pod を復元し、同じ jsonl に後続 turn を追記する | diff --git a/resources/profiles/default.lua b/resources/profiles/default.lua new file mode 100644 index 00000000..04dfee62 --- /dev/null +++ b/resources/profiles/default.lua @@ -0,0 +1,42 @@ +local profile = require("insomnia.profile") +local scope = require("insomnia.scope") +local compact = require("insomnia.compact") + +return profile { + slug = "default", + description = "Bundled default Insomnia coding profile", + + scope = scope.workspace_write(), + + session = { + record_event_trace = true, + }, + + worker = { + reasoning = "high", + }, + + model = { + ref = "codex-oauth/gpt-5.5", + }, + + compaction = compact.tokens { + 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", + }, + }, +}