feat: add nix manifest profile foundation
This commit is contained in:
parent
1d64c50cf2
commit
5de31a9be2
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1732,6 +1732,7 @@ dependencies = [
|
|||
"protocol",
|
||||
"serde",
|
||||
"serde_ignored",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"toml",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ pub struct SpawnConfig {
|
|||
/// (`manifest::paths::pod_runtime_dir`) の解決と、ready 行に乗る
|
||||
/// 名前との突き合わせに使う。
|
||||
pub pod_name: String,
|
||||
/// Optional Nix profile path. 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.
|
||||
pub profile_path: Option<PathBuf>,
|
||||
/// `--overlay` で pod に渡す TOML 文字列。
|
||||
pub overlay_toml: String,
|
||||
/// pod の current_dir。
|
||||
|
|
@ -107,14 +112,21 @@ where
|
|||
|
||||
let mut command = Command::new(&pod_bin);
|
||||
command
|
||||
.arg("--overlay")
|
||||
.arg(&config.overlay_toml)
|
||||
.current_dir(&config.cwd)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::from(stderr_file))
|
||||
.process_group(0);
|
||||
if config.resume_by_pod_name {
|
||||
if let Some(profile_path) = &config.profile_path {
|
||||
command
|
||||
.arg("--profile")
|
||||
.arg(profile_path)
|
||||
.arg("--profile-pod-name")
|
||||
.arg(&config.pod_name);
|
||||
} else {
|
||||
command.arg("--overlay").arg(&config.overlay_toml);
|
||||
}
|
||||
if config.resume_by_pod_name && config.profile_path.is_none() {
|
||||
command.arg("--pod").arg(&config.pod_name);
|
||||
}
|
||||
if let Some(id) = config.resume_from {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ arc-swap = "1"
|
|||
llm-worker = { workspace = true }
|
||||
protocol = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_ignored = "0.1.14"
|
||||
thiserror = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -676,6 +676,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
|
|||
web: cfg.web,
|
||||
memory: cfg.memory,
|
||||
skills: cfg.skills,
|
||||
profile: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ mod config;
|
|||
pub mod defaults;
|
||||
mod model;
|
||||
pub mod paths;
|
||||
mod profile;
|
||||
mod scope;
|
||||
|
||||
pub use cascade::{LayerLoadError, find_project_manifest_from, load_layer};
|
||||
|
|
@ -16,6 +17,10 @@ pub use model::{
|
|||
pub use paths::{
|
||||
user_manifest_path, user_manifest_path_from_env, user_manifest_path_with_env_override,
|
||||
};
|
||||
pub use profile::{
|
||||
NixProfileResolver, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileSelector,
|
||||
ProfileSource, ResolvedProfile, resolve_profile_artifact,
|
||||
};
|
||||
pub use protocol::{Permission, ScopeRule};
|
||||
pub use scope::{Scope, ScopeError, SharedScope};
|
||||
|
||||
|
|
@ -66,6 +71,11 @@ pub struct PodManifest {
|
|||
/// there is no implicit `$config_dir/skills/` or builtin probe.
|
||||
#[serde(default)]
|
||||
pub skills: Option<SkillsConfig>,
|
||||
/// Optional profile provenance for manifests produced by a Nix profile.
|
||||
/// 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")]
|
||||
pub profile: Option<profile::ProfileManifestSnapshot>,
|
||||
}
|
||||
|
||||
/// External Agent Skills (`SKILL.md`) ingest configuration. Skills are
|
||||
|
|
|
|||
|
|
@ -117,6 +117,14 @@ pub enum AuthRef {
|
|||
/// ChatGPT OAuth(`~/.codex/auth.json`)。実装は `llm-auth-codex-oauth` チケット
|
||||
#[serde(rename = "codex_oauth")]
|
||||
CodexOAuth,
|
||||
/// Typed secret-store reference. The profile resolver preserves this
|
||||
/// reference verbatim; secret-store lookup/decryption is intentionally a
|
||||
/// later consumer-boundary concern.
|
||||
#[serde(rename = "secret_ref")]
|
||||
SecretRef {
|
||||
#[serde(rename = "ref")]
|
||||
ref_: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl SchemeKind {
|
||||
|
|
|
|||
428
crates/manifest/src/profile.rs
Normal file
428
crates/manifest/src/profile.rs
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
//! Nix profile 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.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{PodManifest, PodManifestConfig, ResolveError};
|
||||
|
||||
const PROFILE_FORMAT_V1: &str = "insomnia.nix-profile.v1";
|
||||
|
||||
/// 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 { path: PathBuf },
|
||||
}
|
||||
|
||||
impl ProfileSelector {
|
||||
pub fn path(path: impl Into<PathBuf>) -> Self {
|
||||
Self::Path { path: path.into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Profile source recorded with a resolved artifact.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ProfileSource {
|
||||
Path { path: PathBuf },
|
||||
}
|
||||
|
||||
/// Metadata optionally emitted by `mkProfile`.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProfileMetadata {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub format: Option<String>,
|
||||
}
|
||||
|
||||
/// Profile provenance embedded in a resolved manifest snapshot.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProfileManifestSnapshot {
|
||||
pub source: ProfileSource,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<ProfileMetadata>,
|
||||
}
|
||||
|
||||
/// Validated result of evaluating and resolving a profile.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedProfile {
|
||||
pub source: ProfileSource,
|
||||
pub profile: Option<ProfileMetadata>,
|
||||
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,
|
||||
}
|
||||
|
||||
impl Default for NixProfileResolver {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
nix_bin: PathBuf::from("nix"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NixProfileResolver {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_nix_bin(nix_bin: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
nix_bin: nix_bin.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve(&self, selector: &ProfileSelector) -> Result<ResolvedProfile, ProfileError> {
|
||||
match selector {
|
||||
ProfileSelector::Path { path } => self.resolve_path(path),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_path(&self, path: &Path) -> Result<ResolvedProfile, ProfileError> {
|
||||
let absolute_path = absolutize(path)?;
|
||||
let base_dir = absolute_path
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.ok_or_else(|| ProfileError::InvalidPath {
|
||||
path: absolute_path.clone(),
|
||||
message: "profile path has no parent directory".to_string(),
|
||||
})?;
|
||||
|
||||
let 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.clone(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
resolve_profile_artifact(
|
||||
ProfileSource::Path {
|
||||
path: absolute_path,
|
||||
},
|
||||
&base_dir,
|
||||
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(
|
||||
source: ProfileSource,
|
||||
base_dir: &Path,
|
||||
raw_artifact: serde_json::Value,
|
||||
) -> Result<ResolvedProfile, ProfileError> {
|
||||
if !base_dir.is_absolute() {
|
||||
return Err(ProfileError::InvalidPath {
|
||||
path: base_dir.to_path_buf(),
|
||||
message: "profile base directory must be absolute".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
let mut manifest = PodManifest::try_from(config).map_err(ProfileError::ManifestResolve)?;
|
||||
manifest.profile = Some(ProfileManifestSnapshot {
|
||||
source: source.clone(),
|
||||
profile: envelope.profile.clone(),
|
||||
});
|
||||
let manifest_snapshot =
|
||||
serde_json::to_value(&manifest).map_err(ProfileError::SnapshotSerialize)?;
|
||||
|
||||
Ok(ResolvedProfile {
|
||||
source,
|
||||
profile: envelope.profile,
|
||||
manifest,
|
||||
manifest_snapshot,
|
||||
raw_artifact,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ProfileEnvelope {
|
||||
#[serde(default)]
|
||||
profile: Option<ProfileMetadata>,
|
||||
}
|
||||
|
||||
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(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_manifest_value(raw: &serde_json::Value) -> Result<serde_json::Value, ProfileError> {
|
||||
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 absolutize(path: &Path) -> Result<PathBuf, ProfileError> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors raised while evaluating and validating a profile.
|
||||
#[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())]
|
||||
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<i32>,
|
||||
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 decode profile artifact envelope: {source}")]
|
||||
ArtifactShape {
|
||||
#[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}")]
|
||||
ManifestResolve(#[source] ResolveError),
|
||||
|
||||
#[error("failed to serialize resolved manifest snapshot: {0}")]
|
||||
SnapshotSerialize(#[source] serde_json::Error),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{AuthRef, Permission, SchemeKind};
|
||||
|
||||
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 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -806,6 +806,7 @@ mod tests {
|
|||
child("child-stale", &stale_socket),
|
||||
child("child-pending", &pending_socket),
|
||||
],
|
||||
resolved_manifest_snapshot: None,
|
||||
};
|
||||
store.write(&parent).unwrap();
|
||||
store
|
||||
|
|
@ -816,6 +817,7 @@ mod tests {
|
|||
active_child_segment,
|
||||
)),
|
||||
spawned_children: Vec::new(),
|
||||
resolved_manifest_snapshot: None,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
|
|
@ -826,6 +828,7 @@ mod tests {
|
|||
active_child_segment,
|
||||
)),
|
||||
spawned_children: Vec::new(),
|
||||
resolved_manifest_snapshot: None,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
|
|
@ -833,6 +836,7 @@ mod tests {
|
|||
pod_name: "child-pending".into(),
|
||||
active: Some(PodActiveSegmentRef::pending_segment(pending_session_id)),
|
||||
spawned_children: Vec::new(),
|
||||
resolved_manifest_snapshot: None,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
|
|
@ -843,6 +847,7 @@ mod tests {
|
|||
new_segment_id(),
|
||||
)),
|
||||
spawned_children: Vec::new(),
|
||||
resolved_manifest_snapshot: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
|
|||
use std::process::ExitCode;
|
||||
|
||||
use clap::Parser;
|
||||
use manifest::{PodManifest, PodManifestConfig, paths};
|
||||
use manifest::{NixProfileResolver, PodManifest, PodManifestConfig, ProfileSelector, paths};
|
||||
use pod::{Pod, PodController, PodFactory, PromptLoader};
|
||||
use session_store::{FsStore, PodMetadataStore, SegmentId, Store};
|
||||
|
||||
|
|
@ -13,6 +13,21 @@ use session_store::{FsStore, PodMetadataStore, SegmentId, Store};
|
|||
about = "Spawn a Pod process from manifest layers or a single manifest file"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Nix profile to evaluate with `nix eval --json --file <PATH>`.
|
||||
/// Profiles are resolved artifacts, not manifest-cascade layers.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "PATH",
|
||||
conflicts_with_all = ["manifest", "project", "overlay", "pod", "session", "adopt"]
|
||||
)]
|
||||
profile: Option<PathBuf>,
|
||||
|
||||
/// Pod name override for a freshly-created profile Pod. This does not use
|
||||
/// `--pod` restore semantics, so it must not attach/restore existing Pod
|
||||
/// state by re-evaluating the profile source.
|
||||
#[arg(long, value_name = "NAME", requires = "profile", conflicts_with_all = ["pod", "session", "adopt"])]
|
||||
profile_pod_name: Option<String>,
|
||||
|
||||
/// Manifest TOML to use directly, without loading user, project, or
|
||||
/// overlay layers.
|
||||
#[arg(long, value_name = "PATH", conflicts_with_all = ["project", "overlay"])]
|
||||
|
|
@ -75,6 +90,16 @@ fn resolve_manifest_with_user_manifest_env(
|
|||
) -> Result<(PodManifest, PromptLoader), String> {
|
||||
let user_manifest = paths::user_manifest_path_from_env(user_manifest_env);
|
||||
|
||||
if let Some(path) = &cli.profile {
|
||||
if user_manifest.is_some() {
|
||||
return Err(format!(
|
||||
"--profile cannot be used when {} is set",
|
||||
paths::USER_MANIFEST_ENV
|
||||
));
|
||||
}
|
||||
return load_profile(path, cli.profile_pod_name.as_deref());
|
||||
}
|
||||
|
||||
if let Some(path) = &cli.manifest {
|
||||
if user_manifest.is_some() {
|
||||
return Err(format!(
|
||||
|
|
@ -91,6 +116,20 @@ fn resolve_manifest_with_user_manifest_env(
|
|||
.map_err(|e| format!("failed to resolve manifest cascade: {e}"))
|
||||
}
|
||||
|
||||
fn load_profile(
|
||||
path: &Path,
|
||||
pod_name_override: Option<&str>,
|
||||
) -> Result<(PodManifest, PromptLoader), String> {
|
||||
let resolver = NixProfileResolver::new();
|
||||
let mut resolved = resolver
|
||||
.resolve(&ProfileSelector::path(path.to_path_buf()))
|
||||
.map_err(|e| format!("failed to resolve profile {}: {e}", path.display()))?;
|
||||
if let Some(pod_name) = pod_name_override {
|
||||
resolved.manifest.pod.name = pod_name.to_string();
|
||||
}
|
||||
Ok((resolved.manifest, PromptLoader::builtins_only()))
|
||||
}
|
||||
|
||||
fn load_single_manifest(
|
||||
path: &Path,
|
||||
pod_name_override: Option<&str>,
|
||||
|
|
@ -496,6 +535,45 @@ permission = "write"
|
|||
assert_eq!(manifest.pod.name, "from-flag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_conflicts_with_manifest_and_restore_modes() {
|
||||
let segment_id = session_store::new_segment_id().to_string();
|
||||
for args in [
|
||||
vec!["insomnia-pod", "--profile", "p.nix", "--manifest", "m.toml"],
|
||||
vec!["insomnia-pod", "--profile", "p.nix", "--pod", "agent"],
|
||||
vec![
|
||||
"insomnia-pod",
|
||||
"--profile",
|
||||
"p.nix",
|
||||
"--session",
|
||||
&segment_id,
|
||||
],
|
||||
] {
|
||||
let err = Cli::try_parse_from(args).unwrap_err();
|
||||
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_pod_name_requires_profile() {
|
||||
let err = Cli::try_parse_from(["insomnia-pod", "--profile-pod-name", "agent"]).unwrap_err();
|
||||
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_pod_name_is_not_restore_pod_flag() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"insomnia-pod",
|
||||
"--profile",
|
||||
"p.nix",
|
||||
"--profile-pod-name",
|
||||
"agent",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(cli.profile_pod_name.as_deref(), Some("agent"));
|
||||
assert!(cli.pod.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_mode_loads_single_file_with_minimal_prompt_loader() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
|
|
|||
|
|
@ -917,27 +917,31 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
})
|
||||
}
|
||||
|
||||
fn pod_metadata(&self, active: Option<PodActiveSegmentRef>) -> PodMetadata {
|
||||
let mut metadata = PodMetadata::new(self.manifest.pod.name.clone(), active);
|
||||
if self.manifest.profile.is_some() {
|
||||
metadata.resolved_manifest_snapshot = serde_json::to_value(&self.manifest).ok();
|
||||
}
|
||||
metadata
|
||||
}
|
||||
|
||||
fn write_pod_metadata_pending(&self) -> Result<(), StoreError> {
|
||||
let Some(writer) = &self.pod_metadata_writer else {
|
||||
return Ok(());
|
||||
};
|
||||
writer(PodMetadata::new(
|
||||
self.manifest.pod.name.clone(),
|
||||
Some(PodActiveSegmentRef::pending_segment(self.session_id())),
|
||||
))
|
||||
writer(self.pod_metadata(Some(PodActiveSegmentRef::pending_segment(
|
||||
self.session_id(),
|
||||
))))
|
||||
}
|
||||
|
||||
fn write_pod_metadata_active(&self, loc: SegmentLocation) -> Result<(), StoreError> {
|
||||
let Some(writer) = &self.pod_metadata_writer else {
|
||||
return Ok(());
|
||||
};
|
||||
writer(PodMetadata::new(
|
||||
self.manifest.pod.name.clone(),
|
||||
Some(PodActiveSegmentRef::active_segment(
|
||||
loc.session_id,
|
||||
loc.segment_id,
|
||||
)),
|
||||
))
|
||||
writer(self.pod_metadata(Some(PodActiveSegmentRef::active_segment(
|
||||
loc.session_id,
|
||||
loc.segment_id,
|
||||
))))
|
||||
}
|
||||
|
||||
/// Enable name-keyed Pod metadata write-through for Pods built through
|
||||
|
|
@ -3945,6 +3949,15 @@ where
|
|||
pod_name: pod_name.to_string(),
|
||||
session_id: active.session_id,
|
||||
})?;
|
||||
let manifest = match metadata.resolved_manifest_snapshot {
|
||||
Some(snapshot) => serde_json::from_value(snapshot).map_err(|source| {
|
||||
PodError::PodMetadataManifestSnapshot {
|
||||
pod_name: pod_name.to_string(),
|
||||
source,
|
||||
}
|
||||
})?,
|
||||
None => manifest,
|
||||
};
|
||||
Self::restore_from_manifest(active.session_id, segment_id, manifest, store, loader).await
|
||||
}
|
||||
|
||||
|
|
@ -4618,6 +4631,13 @@ pub enum PodError {
|
|||
pod_name: String,
|
||||
session_id: SessionId,
|
||||
},
|
||||
|
||||
#[error("pod metadata for {pod_name} contains an invalid resolved manifest snapshot: {source}")]
|
||||
PodMetadataManifestSnapshot {
|
||||
pod_name: String,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// Bundle of resources that every high-level Pod constructor needs:
|
||||
|
|
|
|||
|
|
@ -90,6 +90,9 @@ fn resolve_auth(scheme: SchemeKind, auth: &AuthRef) -> Result<ResolvedAuth, Prov
|
|||
.map_err(|e| ProviderError::Config(e.to_string()))?;
|
||||
Ok(ResolvedAuth::Custom(Arc::new(provider)))
|
||||
}
|
||||
AuthRef::SecretRef { ref_ } => Err(ProviderError::Config(format!(
|
||||
"secret store references are not implemented yet: {ref_}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ pub struct PodMetadata {
|
|||
pub active: Option<PodActiveSegmentRef>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub spawned_children: Vec<PodSpawnedChild>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_manifest_snapshot: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl PodMetadata {
|
||||
|
|
@ -76,6 +78,7 @@ impl PodMetadata {
|
|||
pod_name: pod_name.into(),
|
||||
active,
|
||||
spawned_children: Vec::new(),
|
||||
resolved_manifest_snapshot: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -117,3 +120,31 @@ pub(crate) fn validate_pod_name(pod_name: &str) -> Result<(), StoreError> {
|
|||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pod_metadata_manifest_snapshot_roundtrips() {
|
||||
let mut metadata = PodMetadata::new(
|
||||
"profile-pod",
|
||||
Some(PodActiveSegmentRef::pending_segment(crate::new_session_id())),
|
||||
);
|
||||
metadata.resolved_manifest_snapshot = Some(serde_json::json!({
|
||||
"pod": { "name": "profile-pod" },
|
||||
"profile": {
|
||||
"source": { "kind": "path", "path": "/profiles/coder.nix" }
|
||||
}
|
||||
}));
|
||||
|
||||
let json = serde_json::to_string(&metadata).unwrap();
|
||||
let restored: PodMetadata = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(restored, metadata);
|
||||
assert_eq!(
|
||||
restored.resolved_manifest_snapshot.as_ref().unwrap()["profile"]["source"]["kind"],
|
||||
"path"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,9 @@ fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
|
|||
|
||||
#[derive(Debug)]
|
||||
enum Mode {
|
||||
Spawn,
|
||||
Spawn {
|
||||
profile_path: Option<PathBuf>,
|
||||
},
|
||||
/// `insomnia <name>` / `insomnia --pod <name>`: attach to a live Pod by name if
|
||||
/// possible; otherwise launch `insomnia-pod --pod <name>` so the pod process
|
||||
/// resumes from name-keyed state or creates a fresh same-name Pod.
|
||||
|
|
@ -111,6 +113,7 @@ where
|
|||
let mut multi = false;
|
||||
let mut session: Option<SegmentId> = None;
|
||||
let mut pod: Option<String> = None;
|
||||
let mut profile_path: Option<PathBuf> = None;
|
||||
let mut socket_override: Option<PathBuf> = None;
|
||||
let mut socket_seen = false;
|
||||
let mut positional: Option<String> = None;
|
||||
|
|
@ -141,6 +144,13 @@ where
|
|||
pod = Some(raw.clone());
|
||||
i += 2;
|
||||
}
|
||||
"--profile" => {
|
||||
let raw = args
|
||||
.get(i + 1)
|
||||
.ok_or(ParseError::MissingValue("--profile"))?;
|
||||
profile_path = Some(PathBuf::from(raw));
|
||||
i += 2;
|
||||
}
|
||||
"--socket" => {
|
||||
socket_seen = true;
|
||||
let raw = args
|
||||
|
|
@ -187,6 +197,11 @@ where
|
|||
"--multi and --socket are mutually exclusive",
|
||||
));
|
||||
}
|
||||
if profile_path.is_some() {
|
||||
return Err(ParseError::Conflict(
|
||||
"--multi and --profile are mutually exclusive",
|
||||
));
|
||||
}
|
||||
return Ok(Mode::Multi);
|
||||
}
|
||||
|
||||
|
|
@ -205,6 +220,13 @@ where
|
|||
"--pod and --resume are mutually exclusive",
|
||||
));
|
||||
}
|
||||
if profile_path.is_some()
|
||||
&& (resume || session.is_some() || pod.is_some() || positional.is_some() || socket_seen)
|
||||
{
|
||||
return Err(ParseError::Conflict(
|
||||
"--profile can only be used for fresh spawn",
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(pod_name) = pod {
|
||||
return Ok(Mode::PodName {
|
||||
|
|
@ -224,7 +246,7 @@ where
|
|||
socket_override,
|
||||
});
|
||||
}
|
||||
Ok(Mode::Spawn)
|
||||
Ok(Mode::Spawn { profile_path })
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -248,13 +270,13 @@ async fn main() -> ExitCode {
|
|||
}
|
||||
|
||||
let result = match mode {
|
||||
Mode::Spawn => run_spawn(None).await,
|
||||
Mode::Spawn { profile_path } => run_spawn(None, profile_path).await,
|
||||
Mode::PodName {
|
||||
pod_name,
|
||||
socket_override,
|
||||
} => run_pod_name(pod_name, socket_override).await,
|
||||
Mode::Resume => run_resume().await,
|
||||
Mode::ResumeWithSession(id) => run_spawn(Some(id)).await,
|
||||
Mode::ResumeWithSession(id) => run_spawn(Some(id), None).await,
|
||||
Mode::Multi => run_multi().await,
|
||||
};
|
||||
|
||||
|
|
@ -449,8 +471,11 @@ fn is_recoverable_multi_open_error(error: &(dyn std::error::Error + 'static)) ->
|
|||
error.is::<spawn::SpawnError>() || error.is::<NestedOpenCancelled>()
|
||||
}
|
||||
|
||||
async fn run_spawn(resume_from: Option<SegmentId>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let ready = match spawn::run(resume_from).await? {
|
||||
async fn run_spawn(
|
||||
resume_from: Option<SegmentId>,
|
||||
profile_path: Option<PathBuf>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let ready = match spawn::run(resume_from, profile_path).await? {
|
||||
SpawnOutcome::Ready(r) => r,
|
||||
SpawnOutcome::Cancelled => return Ok(()),
|
||||
};
|
||||
|
|
@ -1154,6 +1179,62 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_profile_spawn_mode() {
|
||||
match parse_args_from(["--profile", "/profiles/coder.nix"]).unwrap() {
|
||||
Mode::Spawn { profile_path } => {
|
||||
assert_eq!(profile_path, Some(PathBuf::from("/profiles/coder.nix")));
|
||||
}
|
||||
_ => panic!("expected Spawn mode"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_profile_rejects_resume_attach_modes() {
|
||||
let segment_id = session_store::new_segment_id().to_string();
|
||||
let cases = [
|
||||
(
|
||||
vec![
|
||||
"--profile".to_string(),
|
||||
"p.nix".to_string(),
|
||||
"--resume".to_string(),
|
||||
],
|
||||
"--profile can only be used for fresh spawn",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"--profile".to_string(),
|
||||
"p.nix".to_string(),
|
||||
"--session".to_string(),
|
||||
segment_id,
|
||||
],
|
||||
"--profile can only be used for fresh spawn",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"--profile".to_string(),
|
||||
"p.nix".to_string(),
|
||||
"--socket".to_string(),
|
||||
"/tmp/insomnia/sock".to_string(),
|
||||
],
|
||||
"--profile can only be used for fresh spawn",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"--profile".to_string(),
|
||||
"p.nix".to_string(),
|
||||
"agent".to_string(),
|
||||
],
|
||||
"--profile can only be used for fresh spawn",
|
||||
),
|
||||
];
|
||||
|
||||
for (args, message) in cases {
|
||||
let err = parse_args_from(args).unwrap_err();
|
||||
assert_eq!(err.to_string(), message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multi_mode() {
|
||||
match parse_args_from(["--multi"]).unwrap() {
|
||||
|
|
|
|||
|
|
@ -91,13 +91,20 @@ type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
|
|||
/// Source session for a resume run. `None` = fresh spawn (current
|
||||
/// behaviour); `Some(id)` swaps the dialog into "Resume Pod" mode and
|
||||
/// passes `--session <id>` to the spawned `insomnia-pod` child.
|
||||
pub async fn run(resume_from: Option<SegmentId>) -> Result<SpawnOutcome, SpawnError> {
|
||||
pub async fn run(
|
||||
resume_from: Option<SegmentId>,
|
||||
profile_path: Option<PathBuf>,
|
||||
) -> Result<SpawnOutcome, SpawnError> {
|
||||
let defaults = load_spawn_defaults()?;
|
||||
let scope_origin = match profile_path.as_ref() {
|
||||
Some(path) => ScopeOrigin::FromProfile(path.clone()),
|
||||
None => defaults.scope_origin,
|
||||
};
|
||||
|
||||
let mut form = Form {
|
||||
cwd: defaults.cwd.clone(),
|
||||
cascade_has_scope: defaults.cascade_has_scope,
|
||||
scope_origin: defaults.scope_origin,
|
||||
scope_origin,
|
||||
name_cursor: defaults.default_name.chars().count(),
|
||||
name: defaults.default_name,
|
||||
message: None,
|
||||
|
|
@ -105,6 +112,7 @@ pub async fn run(resume_from: Option<SegmentId>) -> Result<SpawnOutcome, SpawnEr
|
|||
resume_from,
|
||||
resume_by_pod_name: false,
|
||||
resume_scope: None,
|
||||
profile_path,
|
||||
};
|
||||
|
||||
let mut terminal = make_inline_terminal()?;
|
||||
|
|
@ -279,6 +287,7 @@ fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form {
|
|||
resume_from: None,
|
||||
resume_by_pod_name: true,
|
||||
resume_scope: None,
|
||||
profile_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -352,6 +361,7 @@ async fn wait_for_ready(
|
|||
|
||||
let config = SpawnConfig {
|
||||
pod_name: form.name.clone(),
|
||||
profile_path: form.profile_path.clone(),
|
||||
overlay_toml: overlay_toml.to_string(),
|
||||
cwd,
|
||||
resume_from: form.resume_from,
|
||||
|
|
@ -428,6 +438,7 @@ enum ScopeOrigin {
|
|||
FromUser,
|
||||
FromProject,
|
||||
CwdDefault,
|
||||
FromProfile(PathBuf),
|
||||
}
|
||||
|
||||
struct Form {
|
||||
|
|
@ -462,6 +473,10 @@ struct Form {
|
|||
/// resume runs, and serialized into the overlay instead of cwd-default
|
||||
/// scope so resume does not silently broaden access.
|
||||
resume_scope: Option<ScopeConfig>,
|
||||
/// Optional Nix profile 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_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Form {
|
||||
|
|
@ -593,6 +608,15 @@ fn context_line(form: &Form) -> Line<'_> {
|
|||
),
|
||||
Span::styled(" (write, default)", Style::default().fg(Color::DarkGray)),
|
||||
]),
|
||||
ScopeOrigin::FromProfile(ref path) => Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("profile: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
path.display().to_string(),
|
||||
Style::default().fg(Color::Green),
|
||||
),
|
||||
Span::styled(" (resolved by pod)", Style::default().fg(Color::DarkGray)),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -639,6 +663,7 @@ mod tests {
|
|||
resume_from: None,
|
||||
resume_by_pod_name: false,
|
||||
resume_scope: None,
|
||||
profile_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
56
docs/manifest-profiles.md
Normal file
56
docs/manifest-profiles.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# 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 <path>`, deserializes the resulting JSON artifact, and validates it through the existing `PodManifest` pipeline.
|
||||
|
||||
This keeps composition/import/common logic in Nix. Insomnia does not add an implicit profile cascade or merge TOML profile layers at runtime.
|
||||
|
||||
## 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"; }
|
||||
];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Run it with:
|
||||
|
||||
```sh
|
||||
insomnia-pod --profile ./coder.nix
|
||||
# or through the TUI fresh-spawn dialog
|
||||
insomnia --profile ./coder.nix
|
||||
```
|
||||
|
||||
`--profile` conflicts with `insomnia-pod --manifest` and with restore/session/adopt modes. Use `--profile-pod-name <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 should restore saved Pod state/resolved snapshots rather than re-evaluating the Nix source.
|
||||
|
||||
## Artifact 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 `manifest`/`config` object uses the same field names as the existing manifest config. Relative paths are resolved against the directory containing the profile file, then builtin defaults are merged and validation produces the runtime `PodManifest`.
|
||||
|
||||
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.
|
||||
67
resources/nix/profile-lib.nix
Normal file
67
resources/nix/profile-lib.nix
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Insomnia Nix profile helpers.
|
||||
#
|
||||
# A profile file can use:
|
||||
#
|
||||
# let insomnia = import ./path/to/profile-lib.nix {};
|
||||
# in insomnia.mkProfile {
|
||||
# name = "coder";
|
||||
# manifest = insomnia.mkManifest { ... };
|
||||
# }
|
||||
#
|
||||
# The output is consumed by `insomnia-pod --profile <path>` via
|
||||
# `nix eval --json --file <path>`.
|
||||
|
||||
{ }:
|
||||
|
||||
let
|
||||
profileFormat = "insomnia.nix-profile.v1";
|
||||
|
||||
optional = name: value:
|
||||
if value == null then {} else { ${name} = value; };
|
||||
|
||||
secretRef = ref: {
|
||||
kind = "secret_ref";
|
||||
inherit ref;
|
||||
};
|
||||
|
||||
mkManifest = manifest: manifest;
|
||||
|
||||
mkProfile =
|
||||
{ name ? null
|
||||
, description ? null
|
||||
, manifest ? null
|
||||
, config ? null
|
||||
, ...
|
||||
}@args:
|
||||
let
|
||||
resolvedManifest =
|
||||
if manifest != null then manifest
|
||||
else if config != null then config
|
||||
else removeAttrs args [ "name" "description" "manifest" "config" ];
|
||||
in
|
||||
{
|
||||
profile = ({ format = profileFormat; }
|
||||
// optional "name" name
|
||||
// optional "description" description);
|
||||
manifest = resolvedManifest;
|
||||
};
|
||||
|
||||
semanticPresets = {
|
||||
# Skeleton for users to extend in their own Nix. Rust does not attach any
|
||||
# hidden semantic meaning to these helpers; they only generate manifest JSON.
|
||||
codingAssistant = { modelId ? "claude-sonnet-4-20250514", authRef ? null }:
|
||||
{
|
||||
model = {
|
||||
scheme = "anthropic";
|
||||
model_id = modelId;
|
||||
} // (if authRef == null then {} else { auth = secretRef authRef; });
|
||||
};
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
inherit profileFormat mkProfile mkManifest semanticPresets;
|
||||
secrets = {
|
||||
ref = secretRef;
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user