fix: remove direct worker local workspace discovery

This commit is contained in:
Keisuke Hirata 2026-07-21 12:07:38 +09:00
parent 1251edae04
commit 00d6c3a7c4
No known key found for this signature in database

View File

@ -4,24 +4,18 @@ use std::process::ExitCode;
use crate::{
PromptLoader, Worker, WorkerController, WorkerFilesystemAuthority, WorkerWorkspaceContext,
WorkspaceId,
};
use clap::{CommandFactory, FromArgMatches, Parser};
use manifest::{
Permission, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig, ScopeRule,
WorkerManifest, WorkerManifestConfig, paths,
plugin::{PluginDiscoveryOptions, resolve_plugin_config_for_startup},
};
use manifest::{Permission, ScopeConfig, ScopeRule, WorkerManifest, WorkerManifestConfig, paths};
use session_store::{CombinedStore, FsWorkerStore, WorkerMetadataStore};
use session_store::{FsStore, SegmentId, Store};
use ticket::config::TicketRole;
#[derive(Debug, Parser)]
#[command(about = "Spawn a Worker process from a profile or a single manifest file")]
#[command(about = "Spawn a Worker process from a manifest or builtin defaults")]
struct Cli {
/// Profile to evaluate. Accepts an explicit path, `path:<path>`, a
/// discovered profile name, `default`, or a source-qualified name such as
/// `project:coder`.
/// Legacy profile selector. Direct worker startup no longer resolves
/// workspace-local profiles; pass a Backend-resolved manifest instead.
#[arg(
long,
value_name = "PROFILE",
@ -29,8 +23,8 @@ struct Cli {
)]
profile: Option<String>,
/// Runtime workspace root for profile discovery, default Worker naming, and process context.
/// Defaults to the current directory.
/// Process root/cwd context for direct worker startup. This is not Workspace
/// authority and does not enable `.yoi` discovery. Defaults to current dir.
#[arg(long, value_name = "PATH")]
workspace: Option<PathBuf>,
@ -106,22 +100,8 @@ fn runtime_workspace_root(cli: &Cli) -> Result<PathBuf, String> {
}
}
fn runtime_workspace_context(workspace_root: &Path) -> WorkerWorkspaceContext {
WorkerWorkspaceContext::local_filesystem(read_workspace_id_hint(workspace_root))
}
fn read_workspace_id_hint(workspace_root: &Path) -> Option<WorkspaceId> {
let path = workspace_root.join(".yoi/workspace.toml");
let contents = std::fs::read_to_string(path).ok()?;
let value = toml::from_str::<toml::Value>(&contents).ok()?;
let id = value.get("id")?.as_str()?.to_string();
match WorkspaceId::new(id) {
Ok(id) => Some(id),
Err(err) => {
tracing::warn!("ignoring invalid workspace id in .yoi/workspace.toml: {err}");
None
}
}
fn runtime_workspace_context() -> WorkerWorkspaceContext {
WorkerWorkspaceContext::no_workspace()
}
fn runtime_worker_name(cli: &Cli, workspace_root: &Path) -> String {
@ -158,63 +138,39 @@ fn sanitise_worker_name(raw: &str) -> String {
}
fn resolve_manifest(cli: &Cli) -> Result<(WorkerManifest, PromptLoader), String> {
resolve_manifest_with_profile_loader(cli, load_profile)
}
fn resolve_manifest_with_profile_loader<F>(
cli: &Cli,
load_profile_fn: F,
) -> Result<(WorkerManifest, PromptLoader), String>
where
F: FnOnce(&ProfileSelector, &Path, &str) -> Result<(WorkerManifest, PromptLoader), String>,
let process_root = runtime_workspace_root(cli)?;
let runtime_worker_name = runtime_worker_name(cli, &process_root);
let ((mut manifest, loader), apply_direct_launch_policy) = if let Some(config_json) =
cli.spawn_config_json.as_deref()
{
let workspace_root = runtime_workspace_root(cli)?;
let runtime_worker_name = runtime_worker_name(cli, &workspace_root);
let ((mut manifest, loader), manifest_source) =
if let Some(config_json) = cli.spawn_config_json.as_deref() {
(
load_spawn_config_json(config_json)?,
ManifestSource::SpawnConfig,
)
(load_spawn_config_json(config_json)?, false)
} else if let Some(profile) = &cli.profile {
let selector = ProfileSelector::parse_cli(profile);
(
load_profile_fn(&selector, &workspace_root, &runtime_worker_name)?,
ManifestSource::ProfileLaunch,
)
return Err(format!(
"--profile {profile} requires Backend-resolved manifest authority; direct worker startup does not discover workspace-local profiles"
));
} else if let Some(path) = &cli.manifest {
(
load_single_manifest(path, cli.worker.as_deref(), &runtime_worker_name)?,
ManifestSource::ManifestFile,
false,
)
} else {
if cli.project.is_some() {
return Err(
"--project is no longer supported; normal startup uses profile discovery/default, \
and --manifest <PATH> is the only one-file manifest mode"
"--project is no longer supported; direct worker startup uses --manifest, \
--spawn-config-json, or builtin defaults only"
.to_string(),
);
}
let selector = ProfileSelector::Default;
(
load_profile_fn(&selector, &workspace_root, &runtime_worker_name)?,
ManifestSource::ProfileLaunch,
)
(load_builtin_default_manifest(&runtime_worker_name)?, true)
};
if manifest_source == ManifestSource::ProfileLaunch {
apply_profile_launch_policy(&mut manifest, &workspace_root, cli.ticket_role.as_deref())?;
if apply_direct_launch_policy {
apply_profile_launch_policy(&mut manifest, &process_root, cli.ticket_role.as_deref())?;
}
apply_session_restore_overrides(&mut manifest, cli)?;
apply_plugin_resolution_plan(&mut manifest, &workspace_root);
Ok((manifest, loader))
}
fn apply_plugin_resolution_plan(manifest: &mut WorkerManifest, workspace_root: &Path) {
let options = PluginDiscoveryOptions::new(workspace_root);
manifest.plugins = resolve_plugin_config_for_startup(&manifest.plugins, &options);
}
fn apply_session_restore_overrides(manifest: &mut WorkerManifest, cli: &Cli) -> Result<(), String> {
if let Some(worker_name) = cli.worker.as_deref() {
manifest.worker.name = worker_name.to_string();
@ -230,20 +186,14 @@ fn load_spawn_config_json(config_json: &str) -> Result<(WorkerManifest, PromptLo
Ok((manifest, PromptLoader::builtins_only()))
}
fn load_profile(
selector: &ProfileSelector,
workspace_root: &Path,
fn load_builtin_default_manifest(
worker_name: &str,
) -> Result<(WorkerManifest, PromptLoader), String> {
let resolver = ProfileResolver::new().with_workspace_base(workspace_root);
let options = ProfileResolveOptions::with_worker_name(worker_name);
let resolved = resolver.resolve(selector, options).map_err(|e| {
format!(
"failed to resolve profile {}: {e}",
selector.display_label()
)
})?;
Ok((resolved.manifest, PromptLoader::builtins_only()))
let mut config = WorkerManifestConfig::builtin_defaults();
config.worker.name = Some(worker_name.to_string());
let manifest = WorkerManifest::try_from(config)
.map_err(|e| format!("failed to resolve builtin worker defaults: {e}"))?;
Ok((manifest, PromptLoader::builtins_only()))
}
pub fn resolve_runtime_profile_manifest(
@ -268,7 +218,7 @@ pub fn resolve_runtime_profile_manifest_from_manifest(
apply_profile_launch_policy(&mut manifest, workspace_root, None)?;
// Do not run plugin discovery here: runtime-created Workers receive their
// resolved manifest/profile archive from Backend authority, not by scanning
// the materialized workdir's `.yoi/plugins`.
// materialized workdir-local plugin stores.
Ok((manifest, PromptLoader::builtins_only()))
}
@ -282,7 +232,7 @@ pub fn resolve_runtime_profile_manifest_from_manifest_without_filesystem(
}
manifest.scope = ScopeConfig::default();
manifest.delegation_scope = ScopeConfig::default();
// Same as the filesystem-capable runtime path: no local `.yoi` discovery.
// Same as the filesystem-capable runtime path: no local discovery.
Ok((manifest, PromptLoader::builtins_only()))
}
@ -327,13 +277,6 @@ fn load_single_manifest(
Ok((manifest, PromptLoader::builtins_only()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ManifestSource {
ProfileLaunch,
ManifestFile,
SpawnConfig,
}
fn read_rule(target: PathBuf) -> ScopeRule {
ScopeRule {
target,
@ -547,7 +490,7 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
let store = CombinedStore::new(session_store, worker_metadata_store);
let filesystem_authority =
WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone());
let workspace_context = runtime_workspace_context(&workspace_root);
let workspace_context = runtime_workspace_context();
let mut worker = if cli.adopt {
let callback = match cli.callback.clone() {
@ -862,226 +805,46 @@ permission = "write"
}
#[test]
fn profile_launch_preserves_workspace_override_scope_allow_in_final_manifest() {
fn profile_resolution_is_rejected_for_direct_worker_startup() {
let cli = Cli::try_parse_from(["yoi worker", "--profile", "project:coder"]).unwrap();
let err = resolve_manifest(&cli).unwrap_err();
assert!(
err.contains("--profile project:coder requires Backend-resolved manifest authority")
);
}
#[test]
fn normal_startup_uses_builtin_defaults_without_local_profile_discovery() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("runtime-workspace");
let external = tmp.path().join("external-readable");
let yoi_dir = workspace.join(".yoi");
std::fs::create_dir_all(&workspace).unwrap();
std::fs::create_dir_all(&external).unwrap();
std::fs::create_dir_all(&yoi_dir).unwrap();
write(
&yoi_dir.join("override.local.toml"),
&format!(
r#"
[[scope.allow]]
target = "{}"
permission = "read"
recursive = true
"#,
external.display()
),
);
let profile = tmp.path().join("profile.toml");
write(
&profile,
r#"
slug = "override-scope"
[worker]
name = "from-local-override"
[model]
scheme = "anthropic"
model_id = "test-model"
[engine]
language = "override"
"#,
);
let cli = Cli::try_parse_from([
"yoi worker",
"--workspace",
workspace.to_str().unwrap(),
"--profile",
profile.to_str().unwrap(),
])
.unwrap();
let (manifest, _loader) = resolve_manifest(&cli).unwrap();
let snapshot = serde_json::to_value(&manifest).unwrap();
let snapshot_scope: ScopeConfig =
serde_json::from_value(snapshot["scope"].clone()).unwrap();
assert_scope_contains(&manifest.scope.allow, &external, Permission::Read);
assert_scope_contains(&manifest.scope.allow, &workspace, Permission::Write);
assert_scope_contains(
&manifest.scope.deny,
&workspace.join(".worktree"),
Permission::Write,
);
assert_scope_contains(&snapshot_scope.allow, &external, Permission::Read);
assert_scope_contains(&snapshot_scope.allow, &workspace, Permission::Write);
assert_scope_contains(
&snapshot_scope.deny,
&workspace.join(".worktree"),
Permission::Write,
);
}
#[test]
fn profile_uses_selected_profile() {
let tmp = TempDir::new().unwrap();
let profile = tmp.path().join("profile.toml");
let cli = Cli::try_parse_from([
"yoi worker",
"--profile",
profile.to_str().unwrap(),
"--worker",
"from-profile-name",
])
.unwrap();
let mut called = false;
let (manifest, loader) =
resolve_manifest_with_profile_loader(&cli, |selector, _workspace_root, worker_name| {
called = true;
assert_eq!(selector, &ProfileSelector::path(profile.clone()));
assert_eq!(worker_name, "from-profile-name");
let mut manifest =
WorkerManifest::from_toml(&manifest_toml("from-profile", tmp.path())).unwrap();
manifest.worker.name = worker_name.to_string();
Ok((manifest, PromptLoader::builtins_only()))
})
.unwrap();
assert!(called);
assert_eq!(manifest.worker.name, "from-profile-name");
assert!(loader.user_dir().is_none());
assert!(loader.workspace_dir().is_none());
}
#[test]
fn profile_accepts_source_qualified_discovered_name() {
let tmp = TempDir::new().unwrap();
let cli = Cli::try_parse_from([
"yoi worker",
"--profile",
"project:coder",
"--worker",
"from-profile-name",
])
.unwrap();
let mut called = false;
let (manifest, _loader) =
resolve_manifest_with_profile_loader(&cli, |selector, _workspace_root, worker_name| {
called = true;
assert_eq!(
selector,
&ProfileSelector::source_named(
manifest::ProfileRegistrySource::Project,
"coder"
)
);
let mut manifest =
WorkerManifest::from_toml(&manifest_toml("from-profile", tmp.path())).unwrap();
manifest.worker.name = worker_name.to_string();
Ok((manifest, PromptLoader::builtins_only()))
})
.unwrap();
assert!(called);
assert_eq!(manifest.worker.name, "from-profile-name");
}
#[test]
fn profile_without_explicit_worker_uses_workspace_basename_not_selector() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("other-workspace");
std::fs::create_dir(&workspace).unwrap();
let cli = Cli::try_parse_from([
"yoi worker",
"--workspace",
workspace.to_str().unwrap(),
"--profile",
"project:companion",
])
.unwrap();
let mut called = false;
let (manifest, _loader) =
resolve_manifest_with_profile_loader(&cli, |selector, workspace_root, worker_name| {
called = true;
assert_eq!(
selector,
&ProfileSelector::source_named(
manifest::ProfileRegistrySource::Project,
"companion"
)
);
assert_eq!(workspace_root, workspace.as_path());
assert_eq!(worker_name, "other-workspace");
let mut manifest =
WorkerManifest::from_toml(&manifest_toml("profile-selector-name", tmp.path()))
.unwrap();
manifest.worker.name = worker_name.to_string();
Ok((manifest, PromptLoader::builtins_only()))
})
.unwrap();
assert!(called);
assert_eq!(manifest.worker.name, "other-workspace");
}
#[test]
fn normal_startup_uses_default_profile() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("runtime-workspace");
std::fs::create_dir(&workspace).unwrap();
let cli = Cli::try_parse_from(["yoi worker", "--workspace", workspace.to_str().unwrap()])
.unwrap();
let mut called = false;
let (manifest, _loader) =
resolve_manifest_with_profile_loader(&cli, |selector, _workspace_root, worker_name| {
called = true;
assert_eq!(selector, &ProfileSelector::Default);
assert_eq!(worker_name, "runtime-workspace");
let mut manifest =
WorkerManifest::from_toml(&manifest_toml("from-default-profile", tmp.path()))
.unwrap();
manifest.worker.name = worker_name.to_string();
Ok((manifest, PromptLoader::builtins_only()))
})
.unwrap();
let (manifest, loader) = resolve_manifest(&cli).unwrap();
assert!(called);
assert_eq!(manifest.worker.name, "runtime-workspace");
assert_eq!(manifest.scope.allow.len(), 2);
assert_scope_contains(&manifest.scope.allow, tmp.path(), Permission::Write);
assert_ne!(manifest.engine.language, "override");
assert!(loader.user_dir().is_none());
assert!(loader.workspace_dir().is_none());
assert_scope_contains(&manifest.scope.allow, &workspace, Permission::Write);
assert_eq!(manifest.scope.deny.len(), 1);
assert_scope_contains(
&manifest.scope.deny,
&tmp.path().join("runtime-workspace/.worktree"),
Permission::Write,
);
assert_eq!(manifest.delegation_scope.allow.len(), 2);
assert_eq!(
manifest.delegation_scope.allow[0].target,
tmp.path().join("runtime-workspace")
);
assert_eq!(
manifest.delegation_scope.allow[0].permission,
Permission::Read
);
assert_eq!(
manifest.delegation_scope.allow[1].target,
tmp.path().join("runtime-workspace/.worktree")
);
assert_eq!(
manifest.delegation_scope.allow[1].permission,
Permission::Write
);
}
#[test]
fn orchestrator_profile_launch_gets_read_root_and_worktree_delegation_from_launch_policy() {
fn orchestrator_direct_startup_gets_read_root_and_worktree_delegation_from_launch_policy() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("original-workspace");
std::fs::create_dir(&workspace).unwrap();
@ -1089,41 +852,17 @@ model_id = "test-model"
"yoi worker",
"--workspace",
workspace.to_str().unwrap(),
"--profile",
"builtin:orchestrator",
"--ticket-role",
"orchestrator",
])
.unwrap();
let (manifest, _loader) =
resolve_manifest_with_profile_loader(&cli, |selector, _workspace_root, worker_name| {
assert_eq!(
selector,
&ProfileSelector::source_named(
manifest::ProfileRegistrySource::Builtin,
"orchestrator"
)
);
let mut manifest = WorkerManifest::from_toml(&manifest_toml(
"from-orchestrator-profile",
tmp.path(),
))
.unwrap();
manifest.worker.name = worker_name.to_string();
Ok((manifest, PromptLoader::builtins_only()))
})
.unwrap();
let (manifest, _loader) = resolve_manifest(&cli).unwrap();
assert_eq!(manifest.scope.allow.len(), 2);
assert_scope_contains(&manifest.scope.allow, tmp.path(), Permission::Write);
assert_scope_contains(&manifest.scope.allow, &workspace, Permission::Read);
assert!(manifest.scope.deny.is_empty());
assert_eq!(manifest.delegation_scope.allow.len(), 2);
assert_eq!(
manifest.delegation_scope.allow[0].target,
tmp.path().join("original-workspace")
);
assert_eq!(manifest.delegation_scope.allow[0].target, workspace);
assert_eq!(
manifest.delegation_scope.allow[0].permission,
Permission::Read
@ -1136,23 +875,12 @@ model_id = "test-model"
manifest.delegation_scope.allow[1].permission,
Permission::Write
);
assert!(
!manifest
.delegation_scope
.allow
.iter()
.any(|rule| rule.target == tmp.path().join("original-workspace")
&& rule.permission == Permission::Write)
);
}
#[test]
fn project_flag_no_longer_enables_ambient_manifest_cascade() {
let cli = Cli::try_parse_from(["yoi worker", "--project", "."]).unwrap();
let err = resolve_manifest_with_profile_loader(&cli, |_, _, _| {
panic!("default profile loader must not run when deprecated --project is present")
})
.unwrap_err();
let err = resolve_manifest(&cli).unwrap_err();
assert!(err.contains("--project is no longer supported"));
}
@ -1234,25 +962,11 @@ permission = "write"
}
#[test]
fn worker_flag_with_no_manifest_creates_from_default_profile_with_typed_name() {
let tmp = TempDir::new().unwrap();
fn worker_flag_with_no_manifest_creates_from_builtin_defaults_with_typed_name() {
let cli = Cli::try_parse_from(["yoi worker", "--worker", "agent"]).unwrap();
let mut called = false;
let (manifest, _loader) =
resolve_manifest_with_profile_loader(&cli, |selector, _workspace_root, worker_name| {
called = true;
assert_eq!(selector, &ProfileSelector::Default);
assert_eq!(worker_name, "agent");
let mut manifest =
WorkerManifest::from_toml(&manifest_toml("from-default-profile", tmp.path()))
.unwrap();
manifest.worker.name = worker_name.to_string();
Ok((manifest, PromptLoader::builtins_only()))
})
.unwrap();
let (manifest, _loader) = resolve_manifest(&cli).unwrap();
assert!(called);
assert_eq!(manifest.worker.name, "agent");
}