feat: add nix manifest profile foundation
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
+79
-1
@@ -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();
|
||||
|
||||
+31
-11
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+87
-6
@@ -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() {
|
||||
|
||||
+27
-2
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user