Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
806e24e
docs(spec): relative paths in profile extends (phase 1)
ranguard Oct 6, 2026
a66ee68
docs(spec): add phase 2 save-target menu to extends design
ranguard Oct 6, 2026
729d79a
docs(spec): apply pushback decisions to extends design
ranguard Oct 6, 2026
199f4ae
docs(spec): offer a new user profile only when no file is writable
ranguard Oct 6, 2026
92e656e
docs(plan): implementation plan for relative extends (#2065)
ranguard Oct 6, 2026
ae5593f
docs(plan): apply alignment decisions and add refactor steps (#2065)
ranguard Oct 6, 2026
e9464be
feat(profile): classify relative-path extends entries (#2065)
ranguard Oct 6, 2026
7d3c1cb
feat(profile): resolve relative-path extends entries (#2065)
ranguard Oct 6, 2026
1e9c184
feat(cli): resolve --extends paths against the cwd (#2065)
ranguard Oct 6, 2026
71883bf
feat(profile): follow relative extends in pack detection and profile …
ranguard Oct 6, 2026
a13a183
fix(cli): follow relative extends in pack update hints (#2065)
ranguard Oct 6, 2026
355cc51
fix(cli): keep comments when the save prompt updates a profile (#2065)
ranguard Oct 6, 2026
7ce3ad1
fix(cli): keep conditional entries and null sections in profile updat…
ranguard Oct 6, 2026
b22d52f
feat(profile): record the files a loaded profile came from (#2065)
ranguard Oct 6, 2026
a7ffc07
fix(cli): let the save prompt update a --profile path (#2065)
ranguard Oct 6, 2026
56b3603
feat(cli): choose which profile file the save prompt updates (#2065)
ranguard Oct 6, 2026
734b803
fix(cli): neutral save question and cancel on closed input (#2065)
ranguard Oct 6, 2026
0f9acc3
docs(profile): document relative extends and save targets (#2065)
ranguard Oct 6, 2026
65b5d8b
fix(cli): harden profile save writes against agent-planted files (#2065)
ranguard Oct 6, 2026
02fe09b
fix(cli): ask before saving to a base when the top-level profile is n…
ranguard Oct 6, 2026
48fdcad
docs(profile): use $WORKDIR in the project example and describe base-…
ranguard Oct 6, 2026
1f774c4
fix(cli): classify every profile init extends entry and test file sav…
ranguard Oct 6, 2026
3f5881d
test(cli): pass save-menu test slices without cloning (#2065)
ranguard Oct 6, 2026
2965cf8
test(cli): cover relative extends and --extends paths through the bin…
ranguard Oct 6, 2026
a9af227
refactor(cli): script the save-target menu and save question in tests…
ranguard Oct 6, 2026
08b5419
test(cli): cover pack and jsonc source files, resolved extends, local…
ranguard Oct 6, 2026
475159d
fix(cli): recheck the save target inside write_profile (#2065)
ranguard Oct 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/nono-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ urlencoding = "2"
tempfile.workspace = true
vt100 = "0.16.2"
shlex = "2"
jsonc-parser = { version = "0.34", features = ["serde"] }
jsonc-parser = { version = "0.34", features = ["serde", "cst"] }

[target.'cfg(target_os = "linux")'.dependencies]
nix = { workspace = true, features = ["process", "signal", "fs", "user", "term", "dir"] }
Expand Down
4 changes: 4 additions & 0 deletions crates/nono-cli/src/exec_strategy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ pub(crate) struct ProfileSaveOffer<'a> {
pub(crate) compared_profile: Option<&'a str>,
pub(crate) sandbox_violations: &'a [nono::SandboxViolation],
pub(crate) ignored_denial_paths: &'a [std::path::PathBuf],
pub(crate) profile_save_files: &'a [crate::profile::ProfileSourceFile],
pub(crate) url_denials: &'a [UrlDenialRecord],
}

Expand Down Expand Up @@ -292,6 +293,8 @@ pub struct ExecConfig<'a> {
pub profile_save_base: Option<&'a str>,
/// Denied paths that should not be offered in the save-profile prompt.
pub ignored_denial_paths: &'a [std::path::PathBuf],
/// Writable profile files a save may target, highest precedence first.
pub profile_save_files: &'a [crate::profile::ProfileSourceFile],
/// Non-filesystem sandbox operations suppressed from diagnostic footers.
pub suppressed_system_service_operations: &'a [String],
/// Optional startup timeout for known interactive CLIs that were launched
Expand Down Expand Up @@ -1762,6 +1765,7 @@ pub fn execute_supervised<F: FnMut(i32) -> bool>(
compared_profile: config.profile_save_base,
sandbox_violations: &visible_sandbox_violations,
ignored_denial_paths: config.ignored_denial_paths,
profile_save_files: config.profile_save_files,
url_denials: &url_denials,
};
// An optional profile-save failure never changes the child status.
Expand Down
1 change: 1 addition & 0 deletions crates/nono-cli/src/exec_strategy/clone_files/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ fn config(caps: &CapabilitySet, filesystem: bool, proxy: bool) -> ExecConfig<'_>
protected_paths: &[],
profile_save_base: None,
ignored_denial_paths: &[],
profile_save_files: &[],
suppressed_system_service_operations: &[],
startup_timeout: None,
seccomp_policy: SeccompPolicy {
Expand Down
1 change: 1 addition & 0 deletions crates/nono-cli/src/execution_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,7 @@ pub(crate) fn execute_sandboxed(plan: LaunchPlan) -> Result<()> {
.as_deref()
.or(recommended_profile),
ignored_denial_paths: &flags.ignored_denial_paths,
profile_save_files: &flags.profile_save_files,
suppressed_system_service_operations: &flags.suppressed_system_service_operations,
startup_timeout: flags
.startup_timeout_secs
Expand Down
2 changes: 2 additions & 0 deletions crates/nono-cli/src/launch_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ pub(crate) struct ExecutionFlags {
pub(crate) proc_comm_notify: bool,
pub(crate) bypass_protection_paths: Vec<crate::policy::AppliedBypass>,
pub(crate) ignored_denial_paths: Vec<PathBuf>,
pub(crate) profile_save_files: Vec<crate::profile::ProfileSourceFile>,
pub(crate) suppressed_system_service_operations: Vec<String>,
pub(crate) profile_display_name: Option<String>,
pub(crate) session: SessionLaunchOptions,
Expand Down Expand Up @@ -305,6 +306,7 @@ impl ExecutionFlags {
proc_comm_notify: prepared.proc_comm_notify,
bypass_protection_paths: prepared.bypass_protection_paths.clone(),
ignored_denial_paths: prepared.ignored_denial_paths.clone(),
profile_save_files: prepared.profile_save_files.clone(),
suppressed_system_service_operations: prepared
.suppressed_system_service_operations
.clone(),
Expand Down
3 changes: 3 additions & 0 deletions crates/nono-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ mod platform_client;
mod policy;
mod profile;
mod profile_cmd;
mod profile_file_edit;
mod profile_runtime;
mod profile_save_runtime;
mod protected_paths;
Expand Down Expand Up @@ -326,6 +327,7 @@ mod tests {
open_url_allow_localhost: false,
bypass_protection_paths: Vec::new(),
ignored_denial_paths: Vec::new(),
profile_save_files: Vec::new(),
suppressed_system_service_operations: Vec::new(),
redaction_extra_env_vars: Vec::new(),
network_denial_audit: Default::default(),
Expand Down Expand Up @@ -405,6 +407,7 @@ mod tests {
open_url_allow_localhost: false,
bypass_protection_paths: Vec::new(),
ignored_denial_paths: Vec::new(),
profile_save_files: Vec::new(),
suppressed_system_service_operations: Vec::new(),
redaction_extra_env_vars: Vec::new(),
network_denial_audit: Default::default(),
Expand Down
47 changes: 46 additions & 1 deletion crates/nono-cli/src/pack_update_hint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ fn collect_profile_packs(profile_name: &str) -> Vec<(String, String)> {
}
// Walk extends for all profiles, pack-provided or not, so a user
// profile that extends a pack profile is handled correctly.
if let Some(bases) = crate::profile::load_profile_extends(&name) {
if let Some(bases) = crate::profile::load_profile_extends_resolved(&name) {
queue.extend(bases);
}
}
Expand Down Expand Up @@ -345,4 +345,49 @@ mod tests {

assert!(is_opted_out());
}

#[test]
fn collect_profile_packs_follows_relative_path_extends() {
crate::test_env::with_isolated_config_home(|config_home| {
crate::test_env::write_fake_pack(
config_home,
"acme",
"widget",
"widget",
r#"{ "meta": { "name": "widget" } }"#,
&[],
None,
);
let lockfile = crate::package::lockfile_path().expect("lockfile path");
std::fs::create_dir_all(lockfile.parent().expect("parent")).expect("mkdir");
std::fs::write(
&lockfile,
r#"{ "lockfile_version": 1, "packages": {
"acme/widget": { "version": "1.2.3", "installed_at": "2026-01-01T00:00:00Z" }
} }"#,
)
.expect("write lockfile");

let nono_dir = config_home.join("proj/.nono");
let shared = config_home.join("proj/shared");
std::fs::create_dir_all(&nono_dir).expect("mkdir .nono");
std::fs::create_dir_all(&shared).expect("mkdir shared");
std::fs::write(
shared.join("base.json"),
r#"{ "meta": { "name": "base" }, "extends": "widget" }"#,
)
.expect("write base");
let agent = nono_dir.join("agent.json");
std::fs::write(
&agent,
r#"{ "meta": { "name": "agent" }, "extends": "../shared/base.json" }"#,
)
.expect("write agent");

assert_eq!(
collect_profile_packs(agent.to_str().expect("utf-8 path")),
vec![("acme/widget".to_string(), "1.2.3".to_string())]
);
});
}
}
28 changes: 27 additions & 1 deletion crates/nono-cli/src/package_status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ fn walk_extends_chain(
}
visited.push(name_or_path.to_string());

let Some(bases) = profile::load_profile_extends(name_or_path) else {
let Some(bases) = profile::load_profile_extends_resolved(name_or_path) else {
return false;
};
bases
Expand Down Expand Up @@ -436,6 +436,32 @@ mod tests {
});
}

#[test]
fn claude_code_detection_follows_relative_path_extends() {
with_isolated_config_home(|config_home| {
let nono_dir = config_home.join("proj/.nono");
let shared = config_home.join("proj/shared");
std::fs::create_dir_all(&nono_dir).expect("mkdir .nono");
std::fs::create_dir_all(&shared).expect("mkdir shared");
std::fs::write(
shared.join("base.json"),
r#"{ "meta": { "name": "base" }, "extends": "nolabs-ai/claude" }"#,
)
.expect("write base");
let agent = nono_dir.join("agent.json");
std::fs::write(
&agent,
r#"{ "meta": { "name": "agent" }, "extends": "../shared/base.json" }"#,
)
.expect("write agent");

assert!(
selects_claude_code(agent.to_str().expect("utf-8 path")),
"a relative path base reaching the pack ref must be treated as Claude Code"
);
});
}

/// Aliases live in the pack manifest, not in `CLAUDE_PACK.profiles`, so
/// detection must ask the resolver instead of the hardcoded name list.
#[test]
Expand Down
1 change: 1 addition & 0 deletions crates/nono-cli/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ impl ProfileDef {
command_args: self.command_args.clone(),
unsafe_macos_seatbelt_rules: self.unsafe_macos_seatbelt_rules.clone(),
platform_overrides: self.platform_overrides.clone(),
source_files: Vec::new(),
}
}
}
Expand Down
Loading
Loading