diff --git a/Cargo.lock b/Cargo.lock index ba35ed1c..ef3de4e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2032,6 +2032,7 @@ dependencies = [ "llm-worker", "mlua", "protocol", + "secrets", "serde", "serde_ignored", "serde_json", diff --git a/crates/manifest/Cargo.toml b/crates/manifest/Cargo.toml index fbd3667c..ceae4a9c 100644 --- a/crates/manifest/Cargo.toml +++ b/crates/manifest/Cargo.toml @@ -13,6 +13,7 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_ignored = "0.1.14" sha2 = "0.10" +secrets = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } tracing = { workspace = true } diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index 1137e71c..72b93061 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -6,7 +6,7 @@ //! via [`PodManifestConfig::merge`] and the final config is converted to //! a validated [`PodManifest`] via `TryFrom`. -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::num::NonZeroU32; use std::path::{Path, PathBuf}; @@ -17,10 +17,10 @@ use crate::defaults; use crate::model::{AuthRef, ModelManifest, ReasoningControl}; use crate::plugin::PluginConfig; use crate::{ - CompactionConfig, FeatureConfig, FeatureFlagConfig, FileUploadLimits, MemoryConfig, - PodManifest, PodMeta, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureAccessConfig, - TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, - WorkerManifest, + CompactionConfig, FeatureConfig, FeatureFlagConfig, FileUploadLimits, McpConfig, McpEnvValue, + McpStdioCwdPolicy, MemoryConfig, PodManifest, PodMeta, ScopeConfig, SessionConfig, + SkillsConfig, TicketFeatureAccessConfig, TicketFeatureConfig, ToolOutputLimits, + ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerManifest, }; /// Partial-form Pod manifest. Every field is optional; one or more @@ -57,6 +57,10 @@ pub struct PodManifestConfig { /// separate step and does not run during config merge. #[serde(default)] pub plugins: PluginConfig, + /// Explicit Model Context Protocol provider declarations. Config parsing + /// never starts a local MCP subprocess. + #[serde(default)] + pub mcp: McpConfig, #[serde(default)] pub compaction: Option, /// First-class web tool opt-in. See [`WebConfig`]. @@ -322,6 +326,11 @@ pub enum ResolveError { MissingField(&'static str), #[error("path must be absolute ({field}): {}", .path.display())] RelativePath { field: &'static str, path: PathBuf }, + #[error("invalid MCP config ({field}): {message}")] + InvalidMcpConfig { + field: &'static str, + message: String, + }, } /// Reject manifest fields that were intentionally removed and must not be @@ -436,6 +445,11 @@ impl PodManifestConfig { *dir = join_if_relative(base, dir); } } + for server in &mut self.mcp.stdio_servers { + if let Some(McpStdioCwdPolicy::Path { path }) = &mut server.cwd { + *path = join_if_relative(base, path); + } + } self } @@ -458,6 +472,7 @@ impl PodManifestConfig { ), feature: self.feature.merge(upper.feature), plugins: merge_plugin_config(self.plugins, upper.plugins), + mcp: merge_mcp_config(self.mcp, upper.mcp), compaction: merge_option( self.compaction, upper.compaction, @@ -487,6 +502,11 @@ fn merge_plugin_config(mut base: PluginConfig, upper: PluginConfig) -> PluginCon base } +fn merge_mcp_config(mut base: McpConfig, upper: McpConfig) -> McpConfig { + base.stdio_servers.extend(upper.stdio_servers); + base +} + impl WebConfig { fn merge(self, upper: Self) -> Self { Self { @@ -708,6 +728,149 @@ fn validate_model_paths(model: &ModelManifest, field: &'static str) -> Result<() Ok(()) } +pub(crate) fn validate_mcp_config(mcp: &McpConfig) -> Result<(), ResolveError> { + let mut names = BTreeSet::new(); + for server in &mcp.stdio_servers { + if server.name.trim().is_empty() { + return Err(invalid_mcp( + "mcp.stdio_server.name", + "server name must not be empty", + )); + } + if contains_nul(&server.name) { + return Err(invalid_mcp( + "mcp.stdio_server.name", + "server name must not contain NUL", + )); + } + if !names.insert(server.name.as_str()) { + return Err(invalid_mcp( + "mcp.stdio_server.name", + format!( + "duplicate stdio server name `{}`", + bounded_label(&server.name) + ), + )); + } + + if server.command.trim().is_empty() { + return Err(invalid_mcp( + "mcp.stdio_server.command", + "command must not be empty", + )); + } + if contains_nul(&server.command) { + return Err(invalid_mcp( + "mcp.stdio_server.command", + "command must not contain NUL", + )); + } + for arg in &server.args { + if contains_nul(arg) { + return Err(invalid_mcp( + "mcp.stdio_server.args", + "argument must not contain NUL", + )); + } + } + + if let Some(McpStdioCwdPolicy::Path { path }) = &server.cwd { + if path.as_os_str().is_empty() { + return Err(invalid_mcp( + "mcp.stdio_server.cwd.path", + "cwd path must not be empty", + )); + } + if !path.is_absolute() { + return Err(invalid_mcp( + "mcp.stdio_server.cwd.path", + "cwd path must be absolute after profile/manifest path resolution", + )); + } + } + + for name in &server.env.inherit { + validate_env_name("mcp.stdio_server.env.inherit", name)?; + } + for (name, value) in &server.env.set { + validate_env_name("mcp.stdio_server.env.set", name)?; + match value { + McpEnvValue::Literal { value } => { + if contains_nul(value) { + return Err(invalid_mcp( + "mcp.stdio_server.env.set", + "literal env value must not contain NUL", + )); + } + } + McpEnvValue::SecretRef { ref_ } => { + if secrets::validate_id(ref_).is_err() { + return Err(invalid_mcp( + "mcp.stdio_server.env.set.secret_ref", + "secret_ref must be a valid local secret id", + )); + } + } + McpEnvValue::EnvRef { name } => { + validate_env_name("mcp.stdio_server.env.set.env_ref", name)?; + } + } + } + } + Ok(()) +} + +fn validate_env_name(field: &'static str, name: &str) -> Result<(), ResolveError> { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return Err(invalid_mcp( + field, + "environment variable name must not be empty", + )); + }; + if !(first == '_' || first.is_ascii_alphabetic()) { + return Err(invalid_mcp( + field, + "environment variable name must start with ASCII letter or underscore", + )); + } + if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) { + return Err(invalid_mcp( + field, + "environment variable name must contain only ASCII letters, digits, and underscore", + )); + } + Ok(()) +} + +fn invalid_mcp(field: &'static str, message: impl Into) -> ResolveError { + ResolveError::InvalidMcpConfig { + field, + message: message.into(), + } +} + +fn contains_nul(value: &str) -> bool { + value.as_bytes().contains(&0) +} + +fn bounded_label(value: &str) -> String { + const MAX: usize = 80; + let mut out = String::new(); + for (idx, ch) in value.chars().enumerate() { + if idx >= MAX { + out.push('…'); + break; + } + if ch.is_control() { + out.push('?'); + } else { + out.push(ch); + } + } + out +} + impl TryFrom for PodManifest { type Error = ResolveError; @@ -842,6 +1005,8 @@ impl TryFrom for PodManifest { } } + validate_mcp_config(&cfg.mcp)?; + Ok(PodManifest { pod: PodMeta { name, prompt_pack }, model: cfg.model, @@ -852,6 +1017,7 @@ impl TryFrom for PodManifest { permissions, feature: FeatureConfig::from(cfg.feature), plugins: cfg.plugins, + mcp: cfg.mcp, compaction, web: cfg.web, memory: cfg.memory, @@ -899,6 +1065,7 @@ mod tests { permissions: None, feature: FeatureConfigPartial::default(), plugins: PluginConfig::default(), + mcp: McpConfig::default(), session: None, compaction: None, web: None, @@ -915,6 +1082,139 @@ mod tests { assert!(manifest.permissions.is_none()); } + #[test] + fn resolve_mcp_stdio_config_preserves_explicit_policy() { + let mut cfg = minimal_valid(); + cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig { + name: "filesystem".into(), + command: "node".into(), + args: vec!["server.js".into(), "--root".into()], + cwd: Some(McpStdioCwdPolicy::Path { path: abs("/mcp") }), + env: crate::McpEnvConfig { + inherit: vec!["PATH".into()], + set: std::collections::BTreeMap::from([ + ( + "SAFE_MODE".into(), + McpEnvValue::Literal { value: "1".into() }, + ), + ( + "TOKEN".into(), + McpEnvValue::SecretRef { + ref_: "providers/mcp-token".into(), + }, + ), + ( + "UPSTREAM".into(), + McpEnvValue::EnvRef { + name: "MCP_UPSTREAM_TOKEN".into(), + }, + ), + ]), + }, + }); + + let manifest: PodManifest = cfg.try_into().unwrap(); + + assert_eq!(manifest.mcp.stdio_servers.len(), 1); + let server = &manifest.mcp.stdio_servers[0]; + assert_eq!(server.name, "filesystem"); + assert_eq!(server.command, "node"); + assert_eq!(server.env.inherit, ["PATH"]); + assert!(matches!( + server.env.set["TOKEN"], + McpEnvValue::SecretRef { .. } + )); + } + + #[test] + fn resolve_mcp_rejects_empty_command_and_duplicates() { + let mut cfg = minimal_valid(); + cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig { + name: "dup".into(), + command: "".into(), + args: Vec::new(), + cwd: None, + env: crate::McpEnvConfig::default(), + }); + + let err = PodManifest::try_from(cfg).unwrap_err(); + assert!(matches!( + err, + ResolveError::InvalidMcpConfig { + field: "mcp.stdio_server.command", + .. + } + )); + + let mut cfg = minimal_valid(); + for command in ["one", "two"] { + cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig { + name: "dup".into(), + command: command.into(), + args: Vec::new(), + cwd: None, + env: crate::McpEnvConfig::default(), + }); + } + + let err = PodManifest::try_from(cfg).unwrap_err(); + assert!(matches!( + err, + ResolveError::InvalidMcpConfig { + field: "mcp.stdio_server.name", + .. + } + )); + } + + #[test] + fn resolve_mcp_rejects_invalid_env_and_secret_ref_without_leaking_values() { + let mut cfg = minimal_valid(); + cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig { + name: "secret".into(), + command: "no-such-command-is-not-started".into(), + args: Vec::new(), + cwd: None, + env: crate::McpEnvConfig { + inherit: Vec::new(), + set: std::collections::BTreeMap::from([( + "TOKEN".into(), + McpEnvValue::SecretRef { + ref_: "bad secret id with spaces".into(), + }, + )]), + }, + }); + + let err = PodManifest::try_from(cfg).unwrap_err(); + let rendered = err.to_string(); + assert!(rendered.contains("secret_ref")); + assert!(!rendered.contains("bad secret id with spaces")); + + let value = McpEnvValue::Literal { + value: "plaintext-secret-value".into(), + }; + assert!(!format!("{value:?}").contains("plaintext-secret-value")); + } + + #[test] + fn resolve_mcp_accepts_nonexistent_command_without_autostart() { + let mut cfg = minimal_valid(); + cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig { + name: "later".into(), + command: "definitely-not-a-command-yoi-must-spawn".into(), + args: Vec::new(), + cwd: None, + env: crate::McpEnvConfig::default(), + }); + + let manifest: PodManifest = cfg.try_into().unwrap(); + assert_eq!( + manifest.mcp.stdio_servers[0].command, + "definitely-not-a-command-yoi-must-spawn" + ); + } + #[test] fn resolve_session_record_event_trace() { let mut cfg = minimal_valid(); diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 19319f95..40d6cc88 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -24,10 +24,12 @@ pub use profile::{ pub use protocol::{Permission, ScopeRule}; pub use scope::{DelegationScope, Scope, ScopeError, SharedScope}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; +use std::fmt; use std::num::NonZeroU32; use std::path::PathBuf; +use serde::de::Error as _; use serde::{Deserialize, Serialize}; /// Declarative configuration for a Pod. @@ -62,6 +64,12 @@ pub struct PodManifest { /// source-qualified entries listed here may resolve to active plugin metadata. #[serde(default)] pub plugins: plugin::PluginConfig, + /// Explicit external Model Context Protocol provider configuration. This + /// is config data only: declaring a server never starts a subprocess or + /// grants OS sandboxing. Runtime MCP lifecycle/registration is a separate + /// consumer boundary. + #[serde(default)] + pub mcp: McpConfig, #[serde(default)] pub compaction: Option, /// Memory subsystem configuration. Presence of `[memory]` configures memory @@ -194,6 +202,92 @@ pub struct SkillsConfig { pub directories: Vec, } +/// Explicit Model Context Protocol configuration. +/// +/// The manifest layer records local stdio MCP server declarations but never +/// starts them. Future lifecycle code must opt in to spawning and must keep MCP +/// process authority separate from Plugin permissions and `pod::feature` flags. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct McpConfig { + /// Named local stdio servers. The list form keeps declarations explicit and + /// lets validation reject duplicate names after profile/override merging. + #[serde(default, rename = "stdio_server")] + pub stdio_servers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct McpStdioServerConfig { + /// Stable profile-local name used by later lifecycle/tool-surface code. + pub name: String, + /// Executable path/name passed directly to process-spawn code in a later + /// ticket. This is not a shell string and is not executed by config parsing. + pub command: String, + #[serde(default)] + pub args: Vec, + /// Optional working-directory policy for the future subprocess. Omitted + /// means no config-level cwd override. Relative `path` values are resolved + /// against the manifest/profile layer before final validation. + #[serde(default)] + pub cwd: Option, + /// Explicit environment policy. There is no implicit environment discovery; + /// future spawn code should inherit only names listed here and set only + /// entries declared here. + #[serde(default)] + pub env: McpEnvConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum McpStdioCwdPolicy { + /// Leave cwd selection to the lifecycle caller. + Inherit, + /// Use this absolute (after path resolution) working directory. + Path { path: PathBuf }, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct McpEnvConfig { + /// Host environment variable names to copy explicitly at spawn time. + #[serde(default)] + pub inherit: Vec, + /// Environment variables to set explicitly. + #[serde(default)] + pub set: BTreeMap, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum McpEnvValue { + /// Literal value. Use only for non-secret values; Debug/diagnostics redact + /// it defensively because env values often become credentials over time. + Literal { value: String }, + /// Local secret-store id. The plaintext is resolved only by a future runtime + /// consumer and is never loaded during manifest/profile parsing. + #[serde(rename = "secret_ref")] + SecretRef { + #[serde(rename = "ref")] + ref_: String, + }, + /// Name of a host environment variable to read explicitly at spawn time. + EnvRef { name: String }, +} + +impl fmt::Debug for McpEnvValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Literal { .. } => f + .debug_struct("Literal") + .field("value", &"[redacted]") + .finish(), + Self::SecretRef { ref_ } => f.debug_struct("SecretRef").field("ref_", ref_).finish(), + Self::EnvRef { name } => f.debug_struct("EnvRef").field("name", name).finish(), + } + } +} + /// Configuration for WebSearch and WebFetch built-in tools. /// /// Network tools are fail-closed: absent config or `enabled = false` disables @@ -712,7 +806,10 @@ impl PodManifest { /// Parse a manifest from a TOML string. pub fn from_toml(s: &str) -> Result { config::reject_removed_manifest_fields(s)?; - toml::from_str(s) + let manifest: Self = toml::from_str(s)?; + config::validate_mcp_config(&manifest.mcp) + .map_err(|error| toml::de::Error::custom(error.to_string()))?; + Ok(manifest) } } diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 2170a977..03a8fac6 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -19,8 +19,9 @@ use crate::config::{ use crate::model::{AuthRef, ModelManifest}; use crate::plugin::PluginConfig; use crate::{ - MemoryConfig, Permission, PodManifest, PodManifestConfig, PodMetaConfig, ResolveError, - ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifestConfig, paths, + McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, PodManifest, PodManifestConfig, + PodMetaConfig, ResolveError, ScopeConfig, ScopeRule, SkillsConfig, WebConfig, + WorkerManifestConfig, paths, }; const PROFILE_FORMAT_V1: &str = "yoi.lua-profile.v1"; @@ -628,6 +629,7 @@ fn resolve_lua_profile_value( permissions: profile.permissions, feature: profile.feature, plugins: profile.plugins, + mcp: profile.mcp, compaction, web: profile.web, memory: profile.memory, @@ -691,6 +693,8 @@ struct ProfileConfig { #[serde(default)] plugins: PluginConfig, #[serde(default)] + mcp: McpConfig, + #[serde(default)] compaction: Option, #[serde(default)] web: Option, @@ -1247,6 +1251,16 @@ fn validate_profile_paths(profile: &ProfileConfig) -> Result<(), ProfileError> { } } } + for server in &profile.mcp.stdio_servers { + if let Some(McpStdioCwdPolicy::Path { path }) = &server.cwd + && path.is_absolute() + { + return Err(ProfileError::InvalidProfile( + "field `mcp.stdio_server.cwd.path` must be profile-relative in reusable Profiles" + .into(), + )); + } + } Ok(()) } fn reject_absolute_auth_file( @@ -1693,6 +1707,66 @@ return profile { Some("coder") ); } + + #[test] + fn lua_profile_resolves_named_mcp_stdio_config_without_starting_command() { + let tmp = TempDir::new().unwrap(); + let profile = write_profile( + tmp.path(), + "mcp.lua", + r#" +local profile = require("yoi.profile") +return profile { + slug = "mcp", + model = { scheme = "anthropic", model_id = "claude-sonnet-4-20250514" }, + mcp = { + stdio_server = { + { + name = "filesystem", + command = "definitely-not-spawned-during-profile-resolution", + args = { "--root", "." }, + cwd = { kind = "path", path = "servers" }, + env = { + inherit = { "PATH" }, + set = { + SAFE_MODE = { kind = "literal", value = "1" }, + API_TOKEN = { kind = "secret_ref", ref = "providers/mcp-token" }, + FROM_ENV = { kind = "env_ref", name = "MCP_TOKEN" }, + }, + }, + }, + }, + }, +} +"#, + ); + std::fs::create_dir(tmp.path().join("servers")).unwrap(); + 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(); + + let server = &resolved.manifest.mcp.stdio_servers[0]; + assert_eq!(server.name, "filesystem"); + assert_eq!( + server.command, + "definitely-not-spawned-during-profile-resolution" + ); + assert!(matches!( + server.cwd, + Some(McpStdioCwdPolicy::Path { ref path }) if path == &tmp.path().join("servers") + )); + assert!(matches!( + server.env.set["API_TOKEN"], + crate::McpEnvValue::SecretRef { .. } + )); + } #[test] fn resolves_lua_profile_feature_flags_without_runtime_state() { let tmp = TempDir::new().unwrap(); diff --git a/crates/pod/src/spawn/tool.rs b/crates/pod/src/spawn/tool.rs index 3a5612a6..21693d50 100644 --- a/crates/pod/src/spawn/tool.rs +++ b/crates/pod/src/spawn/tool.rs @@ -776,6 +776,7 @@ fn manifest_to_reusable_config(manifest: &PodManifest) -> PodManifestConfig { }), feature: manifest.feature.clone().into(), plugins: manifest.plugins.clone(), + mcp: manifest.mcp.clone(), compaction: manifest .compaction .as_ref() diff --git a/docs/design/profiles-manifests-prompts.md b/docs/design/profiles-manifests-prompts.md index 76966842..292bdfc3 100644 --- a/docs/design/profiles-manifests-prompts.md +++ b/docs/design/profiles-manifests-prompts.md @@ -29,6 +29,37 @@ Source/partial layers may omit fields. Resolved manifests should be explicit eno For normal Profile/default startup, a workspace may add `.yoi/override.local.toml` as a final local manifest layer. Yoi discovers the nearest ancestor `.yoi/override.local.toml` from the workspace base used for profile resolution, resolves relative paths in that file against its containing `.yoi` directory, and applies it after the selected Profile and builtin defaults. This file is intended for machine-local choices such as provider/model, worker language, prompt pack, and permission policy tweaks; it is ignored by git via the repository `*.local.*` rule. It is not applied in explicit `--manifest ` mode, and it cannot set `pod.name` because Pod identity remains a runtime input. +## Local stdio MCP server declarations + +Profiles and manifest layers may declare named local stdio MCP servers under `mcp.stdio_server`. This is a typed configuration surface only. Declaring a server does not start a subprocess, discover packages, negotiate MCP capabilities, or register tools/resources/prompts. + +Example Lua Profile fragment: + +```lua +mcp = { + stdio_server = { + { + name = "filesystem", + command = "node", + args = { "server.js", "--root", "." }, + cwd = { kind = "path", path = "./mcp" }, + env = { + inherit = { "PATH" }, + set = { + SAFE_MODE = { kind = "literal", value = "1" }, + API_TOKEN = { kind = "secret_ref", ref = "providers/mcp-token" }, + UPSTREAM_TOKEN = { kind = "env_ref", name = "MCP_UPSTREAM_TOKEN" }, + }, + }, + }, + }, +} +``` + +`command` is a direct executable name/path, not a shell string. `args` are passed as argv entries by future lifecycle code. `cwd.kind = "path"` is resolved relative to the Profile or manifest layer; omit `cwd` or use `{ kind = "inherit" }` when the lifecycle caller should choose. Environment handling is explicit: future spawn code should inherit only names listed in `env.inherit` and set only variables in `env.set`. `literal` values are for non-secret data; credentials should use `secret_ref` or explicit `env_ref`. Diagnostics and Debug output must redact env literal values and must not print secret plaintext. + +Local stdio MCP servers are ordinary local executables running with the user's OS permissions. Yoi's feature flags, Plugin permissions, and MCP config validation are not an operating-system sandbox and cannot prevent filesystem/network/process side effects once a later lifecycle implementation chooses to spawn a configured server. + ## Spawned Pods `SpawnPod.profile` is optional and resolves through defaults when omitted. The only concrete capability delegation in the tool call is `SpawnPod.scope`, and it must be a subset of the parent's effective scope. diff --git a/package.nix b/package.nix index fdf427fe..d027b05a 100644 --- a/package.nix +++ b/package.nix @@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-ci9h0U83YQQBeT3xlsGuKULnl1Aphgpg3pR4n0se16I="; + cargoHash = "sha256-Q+z7HDTkLtflth79ptEFy1lkDR9Y5VRrmX0m9NtLVqM="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint,