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_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
+2 -2
View File
@@ -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,
+27 -12
View File
@@ -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<PathBuf>,
#[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<ProfileScopeIntent>,
field: &'static str,
) -> Result<ScopeConfig, ProfileError> {
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);
+78 -5
View File
@@ -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, &registry)
.map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?;
let profile = ProfileResolver::new()
.with_workspace_base(&cwd)
.resolve_for_target(
&self.profile,
.resolve_from_registry(
&selector,
&registry,
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<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> {
let path = std::fs::canonicalize(path)
.map_err(|_| StandaloneLaunchError::WorkingDirectoryUnavailable)?;
@@ -84,3 +109,51 @@ fn canonical_directory(path: &Path) -> Result<PathBuf, StandaloneLaunchError> {
}
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();
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
+34 -9
View File
@@ -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<ProfileChoice> {
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> {
ProfileChoice {
selector,
label,
is_default: entry.is_default,
is_default,
}
})
.collect()
@@ -370,8 +378,8 @@ mod tests {
fn choices() -> Vec<ProfileChoice> {
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(&registry);
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(
&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]
fn default_worker_name_comes_from_sanitised_directory_basename() {
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" // {
slug = "default";
description = "Standalone Yoi coding profile.";
description = "Default Yoi coding profile.";
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";
};
}