fix: restore standalone logical workspace scope

This commit is contained in:
2026-09-24 19:58:03 +09:00
parent 3cd535cc8a
commit 1649e1ad89
9 changed files with 257 additions and 33 deletions
+39 -3
View File
@@ -8,6 +8,7 @@ use crate::profile::ProfileError;
pub const BUILTIN_PROFILE_CATALOG_ID: &str = "builtin-profiles-v2"; pub const BUILTIN_PROFILE_CATALOG_ID: &str = "builtin-profiles-v2";
pub const BUILTIN_DEFAULT_PROFILE: &str = "builtin:default"; pub const BUILTIN_DEFAULT_PROFILE: &str = "builtin:default";
pub const BUILTIN_STANDALONE_PROFILE: &str = "builtin:standalone";
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BuiltinProfileImport { pub struct BuiltinProfileImport {
@@ -25,10 +26,15 @@ pub struct BuiltinProfileResource {
} }
const BASE_PATH: &str = "profiles/base.dcdl"; const BASE_PATH: &str = "profiles/base.dcdl";
const DEFAULT_PATH: &str = "profiles/default.dcdl";
const BASE_IMPORT: &[BuiltinProfileImport] = &[BuiltinProfileImport { const BASE_IMPORT: &[BuiltinProfileImport] = &[BuiltinProfileImport {
specifier: "./base.dcdl", specifier: "./base.dcdl",
resolved_path: BASE_PATH, resolved_path: BASE_PATH,
}]; }];
const DEFAULT_IMPORT: &[BuiltinProfileImport] = &[BuiltinProfileImport {
specifier: "./default.dcdl",
resolved_path: DEFAULT_PATH,
}];
const NO_IMPORTS: &[BuiltinProfileImport] = &[]; const NO_IMPORTS: &[BuiltinProfileImport] = &[];
pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
@@ -41,11 +47,18 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
}, },
BuiltinProfileResource { BuiltinProfileResource {
selector: Some(BUILTIN_DEFAULT_PROFILE), selector: Some(BUILTIN_DEFAULT_PROFILE),
path: "profiles/default.dcdl", path: DEFAULT_PATH,
source: include_str!("../../../resources/profiles/default.dcdl"), source: include_str!("../../../resources/profiles/default.dcdl"),
description: "Standalone Yoi coding profile.", description: "Default Yoi coding profile.",
imports: BASE_IMPORT, 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 { BuiltinProfileResource {
selector: Some("builtin:coder"), selector: Some("builtin:coder"),
path: "profiles/coder.dcdl", path: "profiles/coder.dcdl",
@@ -281,7 +294,11 @@ mod tests {
assert_eq!(catalog.entrypoints.len() + 1, catalog.sources.len()); assert_eq!(catalog.entrypoints.len() + 1, catalog.sources.len());
assert_eq!( assert_eq!(
catalog.entrypoints.get(BUILTIN_DEFAULT_PROFILE), 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:")); assert!(catalog.digest().starts_with("sha256:"));
} }
@@ -300,12 +317,31 @@ mod tests {
assert_eq!(value["feature"]["manage_workdir"]["enabled"], false); 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] #[test]
fn imports_cannot_escape_the_builtin_resource_catalog() { fn imports_cannot_escape_the_builtin_resource_catalog() {
assert_eq!( assert_eq!(
resolve_import_path("profiles/default.dcdl", "./base.dcdl").as_deref(), resolve_import_path("profiles/default.dcdl", "./base.dcdl").as_deref(),
Some("profiles/base.dcdl") Some("profiles/base.dcdl")
); );
assert_eq!(
resolve_import_path("profiles/standalone.dcdl", "./default.dcdl").as_deref(),
Some(DEFAULT_PATH)
);
assert_eq!( assert_eq!(
resolve_import_path("profiles/default.dcdl", "../outside.dcdl"), resolve_import_path("profiles/default.dcdl", "../outside.dcdl"),
None None
+2 -2
View File
@@ -10,8 +10,8 @@ mod scope;
pub use builtin_profile::{ pub use builtin_profile::{
BUILTIN_DEFAULT_PROFILE, BUILTIN_PROFILE_CATALOG_ID, BUILTIN_PROFILE_RESOURCES, BUILTIN_DEFAULT_PROFILE, BUILTIN_PROFILE_CATALOG_ID, BUILTIN_PROFILE_RESOURCES,
BuiltinProfileCatalogSnapshot, BuiltinProfileImport, BuiltinProfileResource, BUILTIN_STANDALONE_PROFILE, BuiltinProfileCatalogSnapshot, BuiltinProfileImport,
builtin_profile_catalog_snapshot, builtin_profile_entrypoints, BuiltinProfileResource, builtin_profile_catalog_snapshot, builtin_profile_entrypoints,
}; };
pub use config::{ pub use config::{
CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial, CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial,
+27 -12
View File
@@ -20,8 +20,8 @@ use crate::config::{
use crate::model::{AuthRef, ModelManifest}; use crate::model::{AuthRef, ModelManifest};
use crate::{ use crate::{
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, Permission, ResolveError, ScopeConfig, EngineManifestConfig, McpConfig, McpStdioCwdPolicy, Permission, ResolveError, ScopeConfig,
ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, WorkerMetaConfig, ScopeRule, SkillsConfig, SymlinkPolicy, WebConfig, WorkerManifest, WorkerManifestConfig,
paths, WorkerMetaConfig, paths,
}; };
const PROFILE_FORMAT_V1: &str = "yoi.profile.v1"; const PROFILE_FORMAT_V1: &str = "yoi.profile.v1";
@@ -698,6 +698,8 @@ struct ProfileScopeTable {
intent: ProfileScopeIntent, intent: ProfileScopeIntent,
#[serde(default)] #[serde(default)]
deny_write: Vec<PathBuf>, deny_write: Vec<PathBuf>,
#[serde(default)]
symlink_policy: SymlinkPolicy,
} }
#[derive(Debug, Clone, Copy, Deserialize)] #[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -938,10 +940,14 @@ fn profile_scope_intent_to_config(
default_intent: Option<ProfileScopeIntent>, default_intent: Option<ProfileScopeIntent>,
field: &'static str, field: &'static str,
) -> Result<ScopeConfig, ProfileError> { ) -> Result<ScopeConfig, ProfileError> {
let (intent, deny_write) = match scope { let (intent, deny_write, symlink_policy) = match scope {
Some(ProfileScopeConfig::Table(table)) => (Some(table.intent), table.deny_write), Some(ProfileScopeConfig::Table(table)) => {
Some(ProfileScopeConfig::String(intent)) => (Some(intent), Vec::new()), (Some(table.intent), table.deny_write, table.symlink_policy)
None => (default_intent, Vec::new()), }
Some(ProfileScopeConfig::String(intent)) => {
(Some(intent), Vec::new(), SymlinkPolicy::default())
}
None => (default_intent, Vec::new(), SymlinkPolicy::default()),
}; };
let Some(intent) = intent else { let Some(intent) = intent else {
return Ok(ScopeConfig::default()); return Ok(ScopeConfig::default());
@@ -969,7 +975,7 @@ fn profile_scope_intent_to_config(
target: workspace_base.to_path_buf(), target: workspace_base.to_path_buf(),
permission, permission,
recursive: true, recursive: true,
symlink_policy: Default::default(), symlink_policy,
}], }],
deny, deny,
}) })
@@ -1247,6 +1253,7 @@ mod tests {
for name in [ for name in [
"default", "default",
"standalone",
"intake", "intake",
"orchestrator", "orchestrator",
"companion", "companion",
@@ -1386,12 +1393,12 @@ mod tests {
} }
#[test] #[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 tmp = TempDir::new().unwrap();
let resolved = ProfileResolver::new() let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path()) .with_workspace_base(tmp.path())
.resolve_for_target( .resolve_for_target(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"), &ProfileSelector::source_named(ProfileRegistrySource::Builtin, "standalone"),
ProfileResolveOptions::with_worker_name("standalone-worker"), ProfileResolveOptions::with_worker_name("standalone-worker"),
ProfileExecutionTarget::Standalone, ProfileExecutionTarget::Standalone,
) )
@@ -1405,17 +1412,25 @@ mod tests {
path: None, path: None,
provenance: Some(provenance), 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.task.enabled);
assert!(resolved.manifest.feature.web.enabled); assert!(resolved.manifest.feature.web.enabled);
assert!(resolved.manifest.feature.image.enabled); assert!(resolved.manifest.feature.image.enabled);
assert!(resolved.manifest.feature.sub_worker.enabled); assert!(resolved.manifest.feature.sub_worker.enabled);
assert!(resolved.manifest.scope.allow.iter().any(|rule| { 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| { 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.memory.profile.enabled);
assert!(!resolved.manifest.feature.ticket.enabled); assert!(!resolved.manifest.feature.ticket.enabled);
+78 -5
View File
@@ -1,8 +1,9 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use manifest::{ use manifest::{
ProfileExecutionTarget, ProfileResolveOptions, ProfileResolver, ProfileSelector, BUILTIN_DEFAULT_PROFILE, BUILTIN_STANDALONE_PROFILE, ProfileDiscovery, ProfileExecutionTarget,
ResolvedProfile, ProfileRegistry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver,
ProfileSelector, ResolvedProfile, validate_profile_execution_target,
}; };
use thiserror::Error; use thiserror::Error;
use worker::PromptCatalogSource; use worker::PromptCatalogSource;
@@ -56,16 +57,23 @@ impl StandaloneLaunchConfig {
return Err(StandaloneLaunchError::PathProfileUnsupported); return Err(StandaloneLaunchError::PathProfileUnsupported);
} }
let cwd = canonical_directory(&self.cwd)?; let cwd = canonical_directory(&self.cwd)?;
let registry = ProfileDiscovery::user_settings()
.discover()
.map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?;
let selector = standalone_profile_selector(&self.profile, &registry)
.map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?;
let profile = ProfileResolver::new() let profile = ProfileResolver::new()
.with_workspace_base(&cwd) .with_workspace_base(&cwd)
.resolve_for_target( .resolve_from_registry(
&self.profile, &selector,
&registry,
ProfileResolveOptions { ProfileResolveOptions {
worker_name: Some(self.worker_name), worker_name: Some(self.worker_name),
}, },
ProfileExecutionTarget::Standalone,
) )
.map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?; .map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?;
validate_profile_execution_target(&profile.manifest, ProfileExecutionTarget::Standalone)
.map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?;
Ok(ResolvedStandaloneLaunch { Ok(ResolvedStandaloneLaunch {
cwd, cwd,
@@ -76,6 +84,23 @@ impl StandaloneLaunchConfig {
} }
} }
fn standalone_profile_selector(
requested: &ProfileSelector,
registry: &ProfileRegistry,
) -> Result<ProfileSelector, manifest::ProfileError> {
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<PathBuf, StandaloneLaunchError> { fn canonical_directory(path: &Path) -> Result<PathBuf, StandaloneLaunchError> {
let path = std::fs::canonicalize(path) let path = std::fs::canonicalize(path)
.map_err(|_| StandaloneLaunchError::WorkingDirectoryUnavailable)?; .map_err(|_| StandaloneLaunchError::WorkingDirectoryUnavailable)?;
@@ -84,3 +109,51 @@ fn canonical_directory(path: &Path) -> Result<PathBuf, StandaloneLaunchError> {
} }
Ok(path) 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, &registry).unwrap(),
ProfileSelector::source_named(ProfileRegistrySource::Builtin, "standalone")
);
let explicit_default =
ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default");
assert_eq!(
standalone_profile_selector(&explicit_default, &registry).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(
&registry_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, &registry).unwrap(),
ProfileSelector::Default
);
assert_eq!(
registry.default_entry().unwrap().qualified_name(),
"user:operator"
);
}
}
+6 -1
View File
@@ -809,7 +809,12 @@ permission = "write"
let mut persisted = Vec::new(); let mut persisted = Vec::new();
write_worker_record(&mut persisted, &decoded).unwrap(); write_worker_record(&mut persisted, &decoded).unwrap();
let persisted: serde_json::Value = serde_json::from_slice(&persisted).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!( assert_eq!(
persisted["manifest"]["manifest"]["feature"]["memory"]["profile"]["enabled"], persisted["manifest"]["manifest"]["feature"]["memory"]["profile"]["enabled"],
false false
+34 -9
View File
@@ -3,7 +3,7 @@ use std::path::Path;
use std::time::Duration; use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; 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::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
@@ -224,12 +224,20 @@ fn run_picker(
} }
fn profile_choices(registry: &manifest::ProfileRegistry) -> Vec<ProfileChoice> { fn profile_choices(registry: &manifest::ProfileRegistry) -> Vec<ProfileChoice> {
let use_standalone_fallback = registry
.default_entry()
.is_ok_and(|entry| entry.qualified_name() == BUILTIN_DEFAULT_PROFILE);
registry registry
.entries() .entries()
.iter() .iter()
.map(|entry| { .map(|entry| {
let selector = entry.qualified_name(); 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}"); let mut label = format!("{selector}{default_marker}");
if let Some(description) = &entry.description { if let Some(description) = &entry.description {
label.push_str(" — "); label.push_str(" — ");
@@ -238,7 +246,7 @@ fn profile_choices(registry: &manifest::ProfileRegistry) -> Vec<ProfileChoice> {
ProfileChoice { ProfileChoice {
selector, selector,
label, label,
is_default: entry.is_default, is_default,
} }
}) })
.collect() .collect()
@@ -370,8 +378,8 @@ mod tests {
fn choices() -> Vec<ProfileChoice> { fn choices() -> Vec<ProfileChoice> {
vec![ vec![
ProfileChoice { ProfileChoice {
selector: "builtin:default".to_owned(), selector: "builtin:standalone".to_owned(),
label: "builtin:default (default) — Default".to_owned(), label: "builtin:standalone (default) — Standalone".to_owned(),
is_default: true, is_default: true,
}, },
ProfileChoice { ProfileChoice {
@@ -386,7 +394,7 @@ mod tests {
fn default_form_preserves_old_spawn_layout_defaults() { fn default_form_preserves_old_spawn_layout_defaults() {
let form = SpawnForm::new(None, "yoi".to_owned(), choices()); let form = SpawnForm::new(None, "yoi".to_owned(), choices());
assert_eq!(form.worker_name, "yoi"); assert_eq!(form.worker_name, "yoi");
assert_eq!(form.selected_profile().selector, "builtin:default"); assert_eq!(form.selected_profile().selector, "builtin:standalone");
} }
#[test] #[test]
@@ -398,7 +406,7 @@ mod tests {
); );
assert_eq!(form.selected_profile().selector, "builtin:coder"); assert_eq!(form.selected_profile().selector, "builtin:coder");
form.apply_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); 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)); form.apply_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(form.selected_profile().selector, "builtin:coder"); assert_eq!(form.selected_profile().selector, "builtin:coder");
} }
@@ -445,7 +453,7 @@ mod tests {
assert!(rendered.contains("spawn worker")); assert!(rendered.contains("spawn worker"));
assert!(rendered.contains("name: yoi")); 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")); assert!(rendered.contains("enter spawn · left/right edit · esc cancel"));
} }
@@ -456,10 +464,27 @@ mod tests {
.unwrap(); .unwrap();
let choices = profile_choices(&registry); let choices = profile_choices(&registry);
let default = choices.iter().find(|choice| choice.is_default).unwrap(); 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)")); 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(
&registry_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(&registry);
let default = choices.iter().find(|choice| choice.is_default).unwrap();
assert_eq!(default.selector, "user:operator");
}
#[test] #[test]
fn default_worker_name_comes_from_sanitised_directory_basename() { fn default_worker_name_comes_from_sanitised_directory_basename() {
assert_eq!( assert_eq!(
@@ -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.
+1 -1
View File
@@ -1,6 +1,6 @@
import "./base.dcdl" // { import "./base.dcdl" // {
slug = "default"; slug = "default";
description = "Standalone Yoi coding profile."; description = "Default Yoi coding profile.";
scope = "workspace_write"; scope = "workspace_write";
delegation_scope = "workspace_write"; delegation_scope = "workspace_write";
+13
View File
@@ -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";
};
}