Skip to content

Commit 4f3ab60

Browse files
authored
feat(profile): add opencode profile and improve config handling (#24)
* test(config): add inherit_global configuration tests - Test merging global and project configs when inherit_global=true - Test skipping global config when inherit_global=false - Test that inherit_global is ignored in custom config files * fix(config): respect inherit_global when using -c flag When -c specifies a config file, treat it as a project config and check inherit_global to decide if the default global config should be merged. Previously, the -c file was always loaded as global config, making inherit_global ineffective. Scenarios now working correctly: - -c with inherit_global=true: merges with ~/.config/sx/config.toml - -c with inherit_global=false: uses config standalone - -c with inherit_base=false: full custom control over allowed paths - No -c flag: loads global config + project .sandbox.toml as before * feat(profile): add opencode profile Adds built-in opencode profile with access to opencode config, cache, and state directories. Includes updates to CLI help text, config template documentation, and comprehensive tests.
1 parent 8861838 commit 4f3ab60

6 files changed

Lines changed: 278 additions & 7 deletions

File tree

profiles/opencode.toml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# OpenCode profile
2+
# Provides access to OpenCode configuration and cache directories
3+
network_mode = "online"
4+
5+
[filesystem]
6+
allow_read = [
7+
# Custom open code
8+
"~/.local/share/opencode",
9+
"~/.local/state/opencode",
10+
"~/.config/opencode",
11+
"~/.cache/opencode",
12+
"/private/tmp/.*",
13+
]
14+
15+
allow_write = [
16+
# Open code
17+
"~/.config/opencode",
18+
"~/.local/share/opencode",
19+
"~/.local/state/opencode",
20+
"~/.cache/opencode",
21+
"/private/tmp/.*",
22+
]
23+
24+
# Directory listing permissions
25+
allow_list_dirs = [
26+
"/private/tmp"
27+
]
28+
29+
[shell]
30+
# No additional environment variables needed
31+
pass_env = []

src/cli/args.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ pub use crate::config::schema::NetworkMode;
1717
localhost Localhost network only\n \
1818
rust Rust/Cargo toolchain\n \
1919
claude Claude Code (~/.claude access)\n \
20-
gpg GPG signing support")]
20+
gpg GPG signing support\n \
21+
opencode OpenCode (~/.config/opencode access)")]
2122
pub struct Args {
2223
/// Enable verbose output (show sandbox config)
2324
#[arg(short, long)]

src/cli/commands.rs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -195,14 +195,24 @@ fn load_effective_config(args: &Args, working_dir: &Path) -> Result<Config> {
195195
return Ok(Config::default());
196196
}
197197

198-
// Load global config
199-
let global =
200-
load_global_config(args.config.as_deref()).context("Failed to load global config")?;
198+
// Explicit -c flag: treat as project config
199+
if let Some(config_path) = &args.config {
200+
let content = std::fs::read_to_string(config_path)
201+
.with_context(|| format!("Failed to read config: {}", config_path.display()))?;
202+
let project: Config = toml::from_str(&content)
203+
.with_context(|| format!("Failed to parse config: {}", config_path.display()))?;
204+
205+
if project.sandbox.inherit_global {
206+
let global = load_global_config(None).context("Failed to load global config")?;
207+
return Ok(merge_configs(&global, &project));
208+
}
209+
return Ok(project);
210+
}
201211

202-
// Load project config
212+
// Default: load global config and project config from working directory
213+
let global = load_global_config(None).context("Failed to load global config")?;
203214
let project = load_project_config(working_dir).context("Failed to load project config")?;
204215

205-
// Merge if project config exists and inherits
206216
match project {
207217
Some(proj) if proj.sandbox.inherit_global => Ok(merge_configs(&global, &proj)),
208218
Some(proj) => Ok(proj),
@@ -382,7 +392,7 @@ fn generate_config_template() -> &'static str {
382392
inherit_global = true
383393
384394
# Profiles to apply for this project
385-
# Available: base, online, localhost, rust, claude, gpg
395+
# Available: base, online, localhost, rust, claude, gpg, opencode
386396
profiles = []
387397
388398
# Default network mode: "offline", "online", or "localhost"

src/config/profile.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ pub enum BuiltinProfile {
102102
Claude,
103103
Gpg,
104104
Bun,
105+
Opencode,
105106
}
106107

107108
impl BuiltinProfile {
@@ -115,6 +116,7 @@ impl BuiltinProfile {
115116
"claude" => Some(Self::Claude),
116117
"gpg" => Some(Self::Gpg),
117118
"bun" => Some(Self::Bun),
119+
"opencode" => Some(Self::Opencode),
118120
_ => None,
119121
}
120122
}
@@ -129,6 +131,7 @@ impl BuiltinProfile {
129131
Self::Claude => "claude",
130132
Self::Gpg => "gpg",
131133
Self::Bun => "bun",
134+
Self::Opencode => "opencode",
132135
}
133136
}
134137

@@ -146,6 +149,7 @@ impl BuiltinProfile {
146149
Self::Claude => include_str!("../../profiles/claude.toml"),
147150
Self::Gpg => include_str!("../../profiles/gpg.toml"),
148151
Self::Bun => include_str!("../../profiles/bun.toml"),
152+
Self::Opencode => include_str!("../../profiles/opencode.toml"),
149153
};
150154
toml::from_str(toml_str).map_err(|e| ProfileError::InvalidBuiltin {
151155
name: self.name(),

tests/config_test.rs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,3 +280,182 @@ fn test_merge_configs_shell_env() {
280280
Some(&"active".to_string())
281281
);
282282
}
283+
284+
#[test]
285+
fn test_inherit_global_true_merges_configs() {
286+
let temp_dir = TempDir::new().unwrap();
287+
288+
// Write a global config
289+
let global_path = temp_dir.path().join("global.toml");
290+
std::fs::write(
291+
&global_path,
292+
r#"
293+
[filesystem]
294+
allow_read = ["~/.gitconfig"]
295+
"#,
296+
)
297+
.unwrap();
298+
299+
// Write a project config with inherit_global = true (default)
300+
let project_path = temp_dir.path().join(".sandbox.toml");
301+
std::fs::write(
302+
&project_path,
303+
r#"
304+
[sandbox]
305+
inherit_global = true
306+
307+
[filesystem]
308+
allow_read = ["~/.claude"]
309+
"#,
310+
)
311+
.unwrap();
312+
313+
let global = load_global_config(Some(&global_path)).unwrap();
314+
let project = load_project_config(temp_dir.path()).unwrap().unwrap();
315+
316+
// Simulate load_effective_config: inherit_global=true → merge
317+
assert!(project.sandbox.inherit_global);
318+
let effective = merge_configs(&global, &project);
319+
320+
// Both global and project paths should be present
321+
assert!(effective.filesystem.allow_read.contains(&"~/.gitconfig".to_string()));
322+
assert!(effective.filesystem.allow_read.contains(&"~/.claude".to_string()));
323+
}
324+
325+
#[test]
326+
fn test_inherit_global_false_skips_global() {
327+
let temp_dir = TempDir::new().unwrap();
328+
329+
// Write a global config with extra paths
330+
let global_path = temp_dir.path().join("global.toml");
331+
std::fs::write(
332+
&global_path,
333+
r#"
334+
[sandbox]
335+
default_network = "online"
336+
337+
[filesystem]
338+
allow_read = ["~/.gitconfig", "~/.cargo"]
339+
"#,
340+
)
341+
.unwrap();
342+
343+
// Write a project config that opts out of global inheritance
344+
let project_path = temp_dir.path().join(".sandbox.toml");
345+
std::fs::write(
346+
&project_path,
347+
r#"
348+
[sandbox]
349+
inherit_global = false
350+
351+
[filesystem]
352+
allow_read = ["~/.claude"]
353+
"#,
354+
)
355+
.unwrap();
356+
357+
let global = load_global_config(Some(&global_path)).unwrap();
358+
let project = load_project_config(temp_dir.path()).unwrap().unwrap();
359+
360+
// Simulate load_effective_config: inherit_global=false → use project only
361+
assert!(!project.sandbox.inherit_global);
362+
let effective = if project.sandbox.inherit_global {
363+
merge_configs(&global, &project)
364+
} else {
365+
project
366+
};
367+
368+
// Only project paths, global paths must NOT be present
369+
assert!(effective.filesystem.allow_read.contains(&"~/.claude".to_string()));
370+
assert!(!effective.filesystem.allow_read.contains(&"~/.gitconfig".to_string()));
371+
assert!(!effective.filesystem.allow_read.contains(&"~/.cargo".to_string()));
372+
// Network stays at project default (offline), not inherited from global (online)
373+
assert_eq!(effective.sandbox.default_network, NetworkMode::Offline);
374+
}
375+
376+
#[test]
377+
fn test_custom_config_inherit_global_false_uses_standalone() {
378+
// When -c specifies a config with inherit_global = false,
379+
// it is used as-is without merging with the global config.
380+
let temp_dir = TempDir::new().unwrap();
381+
382+
let global_path = temp_dir.path().join("global.toml");
383+
std::fs::write(
384+
&global_path,
385+
r#"
386+
[filesystem]
387+
allow_read = ["~/.gitconfig"]
388+
"#,
389+
)
390+
.unwrap();
391+
392+
let custom_path = temp_dir.path().join("custom.toml");
393+
std::fs::write(
394+
&custom_path,
395+
r#"
396+
[sandbox]
397+
inherit_global = false
398+
399+
[filesystem]
400+
allow_read = ["~/.custom"]
401+
"#,
402+
)
403+
.unwrap();
404+
405+
// Simulate load_effective_config with -c flag
406+
let content = std::fs::read_to_string(&custom_path).unwrap();
407+
let project: Config = toml::from_str(&content).unwrap();
408+
409+
// inherit_global = false → use project config as-is
410+
assert!(!project.sandbox.inherit_global);
411+
assert!(project.filesystem.allow_read.contains(&"~/.custom".to_string()));
412+
assert!(!project.filesystem.allow_read.contains(&"~/.gitconfig".to_string()));
413+
}
414+
415+
#[test]
416+
fn test_custom_config_inherit_global_true_merges_with_global() {
417+
// When -c specifies a config with inherit_global = true,
418+
// it is merged with the global config from the default location.
419+
let temp_dir = TempDir::new().unwrap();
420+
421+
let global_path = temp_dir.path().join("global.toml");
422+
std::fs::write(
423+
&global_path,
424+
r#"
425+
[filesystem]
426+
allow_read = ["~/.gitconfig", "~/.config/git/"]
427+
allow_write = ["~/.cache/"]
428+
"#,
429+
)
430+
.unwrap();
431+
432+
let custom_path = temp_dir.path().join("custom.toml");
433+
std::fs::write(
434+
&custom_path,
435+
r#"
436+
[sandbox]
437+
inherit_global = true
438+
profiles = ["online"]
439+
440+
[filesystem]
441+
allow_read = ["~/.custom"]
442+
allow_write = ["~/.custom-data/"]
443+
"#,
444+
)
445+
.unwrap();
446+
447+
// Simulate load_effective_config with -c flag + inherit_global = true
448+
let content = std::fs::read_to_string(&custom_path).unwrap();
449+
let project: Config = toml::from_str(&content).unwrap();
450+
let global = load_global_config(Some(&global_path)).unwrap();
451+
452+
assert!(project.sandbox.inherit_global);
453+
let effective = merge_configs(&global, &project);
454+
455+
// Both global and project paths should be merged
456+
assert!(effective.filesystem.allow_read.contains(&"~/.gitconfig".to_string()));
457+
assert!(effective.filesystem.allow_read.contains(&"~/.config/git/".to_string()));
458+
assert!(effective.filesystem.allow_read.contains(&"~/.custom".to_string()));
459+
assert!(effective.filesystem.allow_write.contains(&"~/.cache/".to_string()));
460+
assert!(effective.filesystem.allow_write.contains(&"~/.custom-data/".to_string()));
461+
}

tests/profile_test.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,5 +212,51 @@ fn test_builtin_profile_from_name() {
212212
Some(BuiltinProfile::Claude)
213213
);
214214
assert_eq!(BuiltinProfile::from_name("gpg"), Some(BuiltinProfile::Gpg));
215+
assert_eq!(
216+
BuiltinProfile::from_name("opencode"),
217+
Some(BuiltinProfile::Opencode)
218+
);
215219
assert_eq!(BuiltinProfile::from_name("unknown"), None);
216220
}
221+
222+
#[test]
223+
fn test_builtin_profile_opencode() {
224+
let profile = BuiltinProfile::Opencode.load().unwrap();
225+
assert_eq!(profile.network_mode, Some(NetworkMode::Online));
226+
assert!(profile
227+
.filesystem
228+
.allow_read
229+
.contains(&"~/.local/share/opencode".to_string()));
230+
assert!(profile
231+
.filesystem
232+
.allow_read
233+
.contains(&"~/.local/state/opencode".to_string()));
234+
assert!(profile
235+
.filesystem
236+
.allow_read
237+
.contains(&"~/.config/opencode".to_string()));
238+
assert!(profile
239+
.filesystem
240+
.allow_read
241+
.contains(&"~/.cache/opencode".to_string()));
242+
assert!(profile
243+
.filesystem
244+
.allow_list_dirs
245+
.contains(&"/private/tmp".to_string()));
246+
assert!(profile
247+
.filesystem
248+
.allow_write
249+
.contains(&"~/.config/opencode".to_string()));
250+
assert!(profile
251+
.filesystem
252+
.allow_write
253+
.contains(&"~/.local/share/opencode".to_string()));
254+
assert!(profile
255+
.filesystem
256+
.allow_write
257+
.contains(&"~/.local/state/opencode".to_string()));
258+
assert!(profile
259+
.filesystem
260+
.allow_write
261+
.contains(&"~/.cache/opencode".to_string()));
262+
}

0 commit comments

Comments
 (0)