diff --git a/crates/manifest/src/builtin_profile.rs b/crates/manifest/src/builtin_profile.rs index a1edfa251..7c0948d3b 100644 --- a/crates/manifest/src/builtin_profile.rs +++ b/crates/manifest/src/builtin_profile.rs @@ -8,6 +8,7 @@ use crate::profile::ProfileError; pub const BUILTIN_PROFILE_CATALOG_ID: &str = "builtin-profiles-v2"; pub const BUILTIN_DEFAULT_PROFILE: &str = "builtin:default"; +pub const BUILTIN_STANDALONE_PROFILE: &str = "builtin:standalone"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct BuiltinProfileImport { @@ -25,10 +26,15 @@ pub struct BuiltinProfileResource { } const BASE_PATH: &str = "profiles/base.dcdl"; +const DEFAULT_PATH: &str = "profiles/default.dcdl"; const BASE_IMPORT: &[BuiltinProfileImport] = &[BuiltinProfileImport { specifier: "./base.dcdl", resolved_path: BASE_PATH, }]; +const DEFAULT_IMPORT: &[BuiltinProfileImport] = &[BuiltinProfileImport { + specifier: "./default.dcdl", + resolved_path: DEFAULT_PATH, +}]; const NO_IMPORTS: &[BuiltinProfileImport] = &[]; pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[ @@ -41,11 +47,18 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[ }, BuiltinProfileResource { selector: Some(BUILTIN_DEFAULT_PROFILE), - path: "profiles/default.dcdl", + path: DEFAULT_PATH, source: include_str!("../../../resources/profiles/default.dcdl"), - description: "Standalone Yoi coding profile.", + description: "Default Yoi coding profile.", imports: BASE_IMPORT, }, + BuiltinProfileResource { + selector: Some(BUILTIN_STANDALONE_PROFILE), + path: "profiles/standalone.dcdl", + source: include_str!("../../../resources/profiles/standalone.dcdl"), + description: "Standalone Yoi coding profile.", + imports: DEFAULT_IMPORT, + }, BuiltinProfileResource { selector: Some("builtin:coder"), path: "profiles/coder.dcdl", @@ -281,7 +294,11 @@ mod tests { assert_eq!(catalog.entrypoints.len() + 1, catalog.sources.len()); assert_eq!( catalog.entrypoints.get(BUILTIN_DEFAULT_PROFILE), - Some(&"profiles/default.dcdl".to_owned()) + Some(&DEFAULT_PATH.to_owned()) + ); + assert_eq!( + catalog.entrypoints.get(BUILTIN_STANDALONE_PROFILE), + Some(&"profiles/standalone.dcdl".to_owned()) ); assert!(catalog.digest().starts_with("sha256:")); } @@ -300,12 +317,31 @@ mod tests { assert_eq!(value["feature"]["manage_workdir"]["enabled"], false); } + #[test] + fn standalone_profile_inherits_default_and_overrides_workspace_policy() { + let value = resolve_builtin_profile_artifact(BUILTIN_STANDALONE_PROFILE) + .expect("evaluate built-in standalone") + .expect("standalone exists"); + assert_eq!(value["slug"], "standalone"); + assert_eq!(value["model"]["ref"], "codex-oauth/gpt-5.6-sol"); + assert_eq!(value["scope"]["intent"], "workspace_write"); + assert_eq!(value["scope"]["symlink_policy"], "logical"); + assert_eq!(value["delegation_scope"]["intent"], "workspace_write"); + assert_eq!(value["delegation_scope"]["symlink_policy"], "logical"); + assert_eq!(value["feature"]["sub_worker"]["enabled"], true); + assert_eq!(value["feature"]["memory"]["enabled"], false); + } + #[test] fn imports_cannot_escape_the_builtin_resource_catalog() { assert_eq!( resolve_import_path("profiles/default.dcdl", "./base.dcdl").as_deref(), Some("profiles/base.dcdl") ); + assert_eq!( + resolve_import_path("profiles/standalone.dcdl", "./default.dcdl").as_deref(), + Some(DEFAULT_PATH) + ); assert_eq!( resolve_import_path("profiles/default.dcdl", "../outside.dcdl"), None diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index aac87acf6..e91c5b913 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -10,8 +10,8 @@ mod scope; pub use builtin_profile::{ BUILTIN_DEFAULT_PROFILE, BUILTIN_PROFILE_CATALOG_ID, BUILTIN_PROFILE_RESOURCES, - BuiltinProfileCatalogSnapshot, BuiltinProfileImport, BuiltinProfileResource, - builtin_profile_catalog_snapshot, builtin_profile_entrypoints, + BUILTIN_STANDALONE_PROFILE, BuiltinProfileCatalogSnapshot, BuiltinProfileImport, + BuiltinProfileResource, builtin_profile_catalog_snapshot, builtin_profile_entrypoints, }; pub use config::{ CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial, diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 7f42ed499..b55df5678 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -20,8 +20,8 @@ use crate::config::{ use crate::model::{AuthRef, ModelManifest}; use crate::{ EngineManifestConfig, McpConfig, McpStdioCwdPolicy, Permission, ResolveError, ScopeConfig, - ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, WorkerMetaConfig, - paths, + ScopeRule, SkillsConfig, SymlinkPolicy, WebConfig, WorkerManifest, WorkerManifestConfig, + WorkerMetaConfig, paths, }; const PROFILE_FORMAT_V1: &str = "yoi.profile.v1"; @@ -698,6 +698,8 @@ struct ProfileScopeTable { intent: ProfileScopeIntent, #[serde(default)] deny_write: Vec, + #[serde(default)] + symlink_policy: SymlinkPolicy, } #[derive(Debug, Clone, Copy, Deserialize)] #[serde(rename_all = "snake_case")] @@ -938,10 +940,14 @@ fn profile_scope_intent_to_config( default_intent: Option, field: &'static str, ) -> Result { - let (intent, deny_write) = match scope { - Some(ProfileScopeConfig::Table(table)) => (Some(table.intent), table.deny_write), - Some(ProfileScopeConfig::String(intent)) => (Some(intent), Vec::new()), - None => (default_intent, Vec::new()), + let (intent, deny_write, symlink_policy) = match scope { + Some(ProfileScopeConfig::Table(table)) => { + (Some(table.intent), table.deny_write, table.symlink_policy) + } + Some(ProfileScopeConfig::String(intent)) => { + (Some(intent), Vec::new(), SymlinkPolicy::default()) + } + None => (default_intent, Vec::new(), SymlinkPolicy::default()), }; let Some(intent) = intent else { return Ok(ScopeConfig::default()); @@ -969,7 +975,7 @@ fn profile_scope_intent_to_config( target: workspace_base.to_path_buf(), permission, recursive: true, - symlink_policy: Default::default(), + symlink_policy, }], deny, }) @@ -1247,6 +1253,7 @@ mod tests { for name in [ "default", + "standalone", "intake", "orchestrator", "companion", @@ -1386,12 +1393,12 @@ mod tests { } #[test] - fn builtin_default_resolves_as_a_standalone_local_capability_profile() { + fn builtin_standalone_resolves_as_a_local_capability_profile() { let tmp = TempDir::new().unwrap(); let resolved = ProfileResolver::new() .with_workspace_base(tmp.path()) .resolve_for_target( - &ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"), + &ProfileSelector::source_named(ProfileRegistrySource::Builtin, "standalone"), ProfileResolveOptions::with_worker_name("standalone-worker"), ProfileExecutionTarget::Standalone, ) @@ -1405,17 +1412,25 @@ mod tests { path: None, provenance: Some(provenance), .. - } if name == "default" && provenance.starts_with("profiles/default.dcdl#sha256:") + } if name == "standalone" && provenance.starts_with("profiles/standalone.dcdl#sha256:") )); + assert_eq!( + resolved.manifest.model.ref_.as_deref(), + Some("codex-oauth/gpt-5.6-sol") + ); assert!(resolved.manifest.feature.task.enabled); assert!(resolved.manifest.feature.web.enabled); assert!(resolved.manifest.feature.image.enabled); assert!(resolved.manifest.feature.sub_worker.enabled); assert!(resolved.manifest.scope.allow.iter().any(|rule| { - rule.permission == protocol::Permission::Write && rule.target == tmp.path() + rule.permission == protocol::Permission::Write + && rule.target == tmp.path() + && rule.symlink_policy == protocol::SymlinkPolicy::Logical })); assert!(resolved.manifest.delegation_scope.allow.iter().any(|rule| { - rule.permission == protocol::Permission::Write && rule.target == tmp.path() + rule.permission == protocol::Permission::Write + && rule.target == tmp.path() + && rule.symlink_policy == protocol::SymlinkPolicy::Logical })); assert!(!resolved.manifest.feature.memory.profile.enabled); assert!(!resolved.manifest.feature.ticket.enabled); diff --git a/crates/standalone/src/launch.rs b/crates/standalone/src/launch.rs index 621d3f809..e65b69bf4 100644 --- a/crates/standalone/src/launch.rs +++ b/crates/standalone/src/launch.rs @@ -1,8 +1,9 @@ use std::path::{Path, PathBuf}; use manifest::{ - ProfileExecutionTarget, ProfileResolveOptions, ProfileResolver, ProfileSelector, - ResolvedProfile, + BUILTIN_DEFAULT_PROFILE, BUILTIN_STANDALONE_PROFILE, ProfileDiscovery, ProfileExecutionTarget, + ProfileRegistry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, + ProfileSelector, ResolvedProfile, validate_profile_execution_target, }; use thiserror::Error; use worker::PromptCatalogSource; @@ -56,16 +57,23 @@ impl StandaloneLaunchConfig { return Err(StandaloneLaunchError::PathProfileUnsupported); } let cwd = canonical_directory(&self.cwd)?; + let registry = ProfileDiscovery::user_settings() + .discover() + .map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?; + let selector = standalone_profile_selector(&self.profile, ®istry) + .map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?; let profile = ProfileResolver::new() .with_workspace_base(&cwd) - .resolve_for_target( - &self.profile, + .resolve_from_registry( + &selector, + ®istry, ProfileResolveOptions { worker_name: Some(self.worker_name), }, - ProfileExecutionTarget::Standalone, ) .map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?; + validate_profile_execution_target(&profile.manifest, ProfileExecutionTarget::Standalone) + .map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?; Ok(ResolvedStandaloneLaunch { cwd, @@ -76,6 +84,23 @@ impl StandaloneLaunchConfig { } } +fn standalone_profile_selector( + requested: &ProfileSelector, + registry: &ProfileRegistry, +) -> Result { + if requested != &ProfileSelector::Default + || registry.default_entry()?.qualified_name() != BUILTIN_DEFAULT_PROFILE + { + return Ok(requested.clone()); + } + Ok(ProfileSelector::source_named( + ProfileRegistrySource::Builtin, + BUILTIN_STANDALONE_PROFILE + .strip_prefix("builtin:") + .expect("built-in standalone selector must be source-qualified"), + )) +} + fn canonical_directory(path: &Path) -> Result { let path = std::fs::canonicalize(path) .map_err(|_| StandaloneLaunchError::WorkingDirectoryUnavailable)?; @@ -84,3 +109,51 @@ fn canonical_directory(path: &Path) -> Result { } Ok(path) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtin_default_maps_to_standalone_only_for_standalone_launch() { + let registry = ProfileDiscovery::with_sources(None, None) + .discover() + .unwrap(); + assert_eq!( + standalone_profile_selector(&ProfileSelector::Default, ®istry).unwrap(), + ProfileSelector::source_named(ProfileRegistrySource::Builtin, "standalone") + ); + let explicit_default = + ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"); + assert_eq!( + standalone_profile_selector(&explicit_default, ®istry).unwrap(), + explicit_default + ); + assert_eq!( + registry.default_entry().unwrap().qualified_name(), + BUILTIN_DEFAULT_PROFILE + ); + } + + #[test] + fn operator_default_remains_the_standalone_default() { + let temp = tempfile::tempdir().unwrap(); + let registry_path = temp.path().join("profiles.toml"); + std::fs::write( + ®istry_path, + "default = 'operator'\n[profile]\noperator = 'operator.toml'\n", + ) + .unwrap(); + let registry = ProfileDiscovery::with_sources(Some(registry_path), None) + .discover() + .unwrap(); + assert_eq!( + standalone_profile_selector(&ProfileSelector::Default, ®istry).unwrap(), + ProfileSelector::Default + ); + assert_eq!( + registry.default_entry().unwrap().qualified_name(), + "user:operator" + ); + } +} diff --git a/crates/standalone/src/store.rs b/crates/standalone/src/store.rs index 0b6db5aa9..f55dfefd0 100644 --- a/crates/standalone/src/store.rs +++ b/crates/standalone/src/store.rs @@ -809,7 +809,12 @@ permission = "write" let mut persisted = Vec::new(); write_worker_record(&mut persisted, &decoded).unwrap(); let persisted: serde_json::Value = serde_json::from_slice(&persisted).unwrap(); - assert_eq!(persisted["manifest"]["schema_version"], 2); + let current_manifest = + manifest::write_persisted_worker_manifest_snapshot(&decoded.manifest).unwrap(); + assert_eq!( + persisted["manifest"]["schema_version"], + current_manifest["schema_version"] + ); assert_eq!( persisted["manifest"]["manifest"]["feature"]["memory"]["profile"]["enabled"], false diff --git a/crates/tui/src/standalone_spawn.rs b/crates/tui/src/standalone_spawn.rs index b6b78fe10..722d1a1aa 100644 --- a/crates/tui/src/standalone_spawn.rs +++ b/crates/tui/src/standalone_spawn.rs @@ -3,7 +3,7 @@ use std::path::Path; use std::time::Duration; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use manifest::ProfileDiscovery; +use manifest::{BUILTIN_DEFAULT_PROFILE, BUILTIN_STANDALONE_PROFILE, ProfileDiscovery}; use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; @@ -224,12 +224,20 @@ fn run_picker( } fn profile_choices(registry: &manifest::ProfileRegistry) -> Vec { + let use_standalone_fallback = registry + .default_entry() + .is_ok_and(|entry| entry.qualified_name() == BUILTIN_DEFAULT_PROFILE); registry .entries() .iter() .map(|entry| { let selector = entry.qualified_name(); - let default_marker = if entry.is_default { " (default)" } else { "" }; + let is_default = if use_standalone_fallback { + selector == BUILTIN_STANDALONE_PROFILE + } else { + entry.is_default + }; + let default_marker = if is_default { " (default)" } else { "" }; let mut label = format!("{selector}{default_marker}"); if let Some(description) = &entry.description { label.push_str(" — "); @@ -238,7 +246,7 @@ fn profile_choices(registry: &manifest::ProfileRegistry) -> Vec { ProfileChoice { selector, label, - is_default: entry.is_default, + is_default, } }) .collect() @@ -370,8 +378,8 @@ mod tests { fn choices() -> Vec { vec![ ProfileChoice { - selector: "builtin:default".to_owned(), - label: "builtin:default (default) — Default".to_owned(), + selector: "builtin:standalone".to_owned(), + label: "builtin:standalone (default) — Standalone".to_owned(), is_default: true, }, ProfileChoice { @@ -386,7 +394,7 @@ mod tests { fn default_form_preserves_old_spawn_layout_defaults() { let form = SpawnForm::new(None, "yoi".to_owned(), choices()); assert_eq!(form.worker_name, "yoi"); - assert_eq!(form.selected_profile().selector, "builtin:default"); + assert_eq!(form.selected_profile().selector, "builtin:standalone"); } #[test] @@ -398,7 +406,7 @@ mod tests { ); assert_eq!(form.selected_profile().selector, "builtin:coder"); form.apply_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - assert_eq!(form.selected_profile().selector, "builtin:default"); + assert_eq!(form.selected_profile().selector, "builtin:standalone"); form.apply_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); assert_eq!(form.selected_profile().selector, "builtin:coder"); } @@ -445,7 +453,7 @@ mod tests { assert!(rendered.contains("spawn worker")); assert!(rendered.contains("name: yoi")); - assert!(rendered.contains("profile: builtin:default (default) — Default")); + assert!(rendered.contains("profile: builtin:standalone (default) — Standalone")); assert!(rendered.contains("enter spawn · left/right edit · esc cancel")); } @@ -456,10 +464,27 @@ mod tests { .unwrap(); let choices = profile_choices(®istry); let default = choices.iter().find(|choice| choice.is_default).unwrap(); - assert_eq!(default.selector, "builtin:default"); + assert_eq!(default.selector, "builtin:standalone"); assert!(default.label.contains("(default)")); } + #[test] + fn operator_profile_default_overrides_standalone_fallback() { + let temp = tempfile::tempdir().unwrap(); + let registry_path = temp.path().join("profiles.toml"); + std::fs::write( + ®istry_path, + "default = 'operator'\n[profile]\noperator = 'operator.toml'\n", + ) + .unwrap(); + let registry = ProfileDiscovery::with_sources(Some(registry_path), None) + .discover() + .unwrap(); + let choices = profile_choices(®istry); + let default = choices.iter().find(|choice| choice.is_default).unwrap(); + assert_eq!(default.selector, "user:operator"); + } + #[test] fn default_worker_name_comes_from_sanitised_directory_basename() { assert_eq!( diff --git a/docs/report/2026-09-24-standalone-workspace-symlink-policy-regression.md b/docs/report/2026-09-24-standalone-workspace-symlink-policy-regression.md new file mode 100644 index 000000000..f3ad36faa --- /dev/null +++ b/docs/report/2026-09-24-standalone-workspace-symlink-policy-regression.md @@ -0,0 +1,57 @@ +# Standalone workspace scope defaults reject logical symlinks + +## Symptom + +A standalone-style Worker rooted at the repository cannot read the repository-local alias: + +```text +yoi.local -> /home/hare/.local/share/yoi/ +``` + +`Read`, `Glob`, and `Grep` reject paths through that alias because the resolved target is outside the repository scope, even though the alias itself is below the Worker root. + +## Cause + +The Workdir provider supports both symlink identities: + +- `Resolved` matches the provider-resolved target and is the least-authority default. +- `Logical` matches the path presented through the Workdir and intentionally permits an in-scope alias to reach its target. + +Focused provider tests confirm both behaviors: + +- an explicit logical policy can read, list, glob, and grep through an external directory alias; +- the default resolved policy rejects a symlink escape. + +Before correction, the standalone launch path did not select the implemented logical behavior. `StandaloneLaunchConfig::resolve` canonicalized the cwd and resolved `builtin:default`. That profile declared `scope = "workspace_write"` and `delegation_scope = "workspace_write"`. `profile_scope_intent_to_config` converted each workspace intent into a recursive root rule with: + +```rust +symlink_policy: Default::default() +``` + +`SymlinkPolicy::default()` is `Resolved`. Consequently, a request for `yoi.local/...` is checked using `/home/hare/.local/share/yoi/...`, which is not below the repository root and is denied. + +This behavior was introduced when selective Workdir symlink policies were added in commit `8a3e06bc`. Provider support for explicit logical rules was added, but reusable profile workspace intents and standalone launch wiring retained the new least-authority default. Reusable profiles also expose only `workspace_read`/`workspace_write` intents, so `builtin:default` cannot opt into logical identity through its profile declaration. + +## Scope of the finding + +This explains the `yoi.local` rejection. It does not explain a rejection of an ordinary path such as `crates` or `Cargo.toml`: those contain no symlink. An error such as: + +```text +Workdir path `crates` exceeds the provider attachment scope +``` + +means the restored parent attachment scope does not cover the Workdir's own logical root. That is a separate restore/attachment authority defect and cannot be fixed by requesting `symlink_policy: logical` on a Reviewer child. + +## Implemented correction + +A dedicated `builtin:standalone` profile now imports `builtin:default` and overrides only the standalone-specific contract: + +- model: `codex-oauth/gpt-5.6-sol`; +- direct workspace scope: `workspace_write` with `Logical` symlink identity; +- delegation scope: `workspace_write` with `Logical` symlink identity. + +Profile scope tables now accept an explicit `symlink_policy`, while the string shorthand and all existing profiles retain the least-authority `Resolved` default. Standalone launch and its TUI picker select `builtin:standalone` only when the registry still has the built-in `builtin:default` fallback; an operator-configured user default retains precedence. The global registry default remains `builtin:default`, so omitted SubWorker profiles do not acquire standalone-specific identity or policy. + +Regression coverage verifies the built-in resource inheritance, model selection, default registry selection, and the concrete direct/delegation scope policies. + +Existing persisted Worker manifest snapshots require explicit consideration: restore reuses the saved resolved scope, so the new profile will not repair already-restored Workers until they are replaced or their persisted authority is migrated deliberately. diff --git a/resources/profiles/default.dcdl b/resources/profiles/default.dcdl index 0bc74caea..e12ca0a97 100644 --- a/resources/profiles/default.dcdl +++ b/resources/profiles/default.dcdl @@ -1,6 +1,6 @@ import "./base.dcdl" // { slug = "default"; - description = "Standalone Yoi coding profile."; + description = "Default Yoi coding profile."; scope = "workspace_write"; delegation_scope = "workspace_write"; diff --git a/resources/profiles/standalone.dcdl b/resources/profiles/standalone.dcdl new file mode 100644 index 000000000..6e0aed313 --- /dev/null +++ b/resources/profiles/standalone.dcdl @@ -0,0 +1,13 @@ +import "./default.dcdl" // { + slug = "standalone"; + description = "Standalone Yoi coding profile."; + model = { ref = "codex-oauth/gpt-5.6-sol"; }; + scope = { + intent = "workspace_write"; + symlink_policy = "logical"; + }; + delegation_scope = { + intent = "workspace_write"; + symlink_policy = "logical"; + }; +}