From 91dced4f61b5ad56775dbf36b111afff3b1caa19 Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:22:06 +0000 Subject: [PATCH 1/2] cleanup(runner): retire legacy config aliases --- crates/webcodex-admin/src/commands.rs | 17 ++-- crates/webcodex-admin/src/tests.rs | 13 +++ crates/webcodex-cli/src/lib.rs | 43 +++++----- .../src/webcodex_cli/connect/disconnect.rs | 13 +-- .../src/webcodex_cli/connect/profile.rs | 48 +++++------ .../webcodex-cli/src/webcodex_cli/project.rs | 37 ++++---- .../src/webcodex_cli/runner_service.rs | 32 +++---- .../src/webcodex_cli/tests/profiles.rs | 41 +++++---- .../src/webcodex_cli/tests/usage.rs | 63 ++++++++------ crates/webcodex-cli/src/webcodex_cli/usage.rs | 1 - crates/webcodex-runner-config/src/paths.rs | 40 +++++---- crates/webcodex-runner/src/main.rs | 6 +- crates/webcodex-runner/src/main_tests.rs | 2 +- .../src/main_tests/runner_config.rs | 85 +++++++++---------- .../src/webcodex_runner/config.rs | 37 ++++---- .../src/webcodex_runner/plugin_check_tests.rs | 2 +- .../src/webcodex_runner/plugin_tests.rs | 2 +- .../src/webcodex_runner/transport_tests.rs | 2 +- docs/RUNNER.md | 16 ++-- docs/RUNNER.zh-CN.md | 13 +-- docs/agent/architecture-decisions.md | 44 +++++----- src/project_entry_setup.rs | 15 ++-- src/project_entry_tests.rs | 33 ++++++- 23 files changed, 326 insertions(+), 279 deletions(-) diff --git a/crates/webcodex-admin/src/commands.rs b/crates/webcodex-admin/src/commands.rs index ec4b27875..a790d5686 100644 --- a/crates/webcodex-admin/src/commands.rs +++ b/crates/webcodex-admin/src/commands.rs @@ -52,7 +52,7 @@ impl FlagParser { } pub fn is_admin_group(arg: &str) -> bool { - matches!(arg, "users" | "tokens" | "runner-tokens" | "agent-tokens") + matches!(arg, "users" | "tokens" | "runner-tokens") } pub fn usage() -> &'static str { @@ -74,6 +74,11 @@ pub fn usage() -> &'static str { } pub fn parse_admin_cli(args: &[String]) -> Result { + if args.first().map(String::as_str) == Some("agent-tokens") { + return Err( + "webcodex agent-tokens was removed; use webcodex runner-tokens instead".to_string(), + ); + } if args.len() < 2 { return Err(format!("missing admin subcommand\n{}", usage())); } @@ -87,12 +92,10 @@ pub fn parse_admin_cli(args: &[String]) -> Result { ("tokens", "register-hash") => parse_tokens_register_hash(rest), ("tokens", "list") => parse_tokens_list(rest), ("tokens", "revoke") => parse_tokens_revoke(rest), - ("runner-tokens" | "agent-tokens", "create") => parse_runner_tokens_create(rest), - ("runner-tokens" | "agent-tokens", "register-hash") => { - parse_runner_tokens_register_hash(rest) - } - ("runner-tokens" | "agent-tokens", "list") => parse_runner_tokens_list(rest), - ("runner-tokens" | "agent-tokens", "revoke") => parse_runner_tokens_revoke(rest), + ("runner-tokens", "create") => parse_runner_tokens_create(rest), + ("runner-tokens", "register-hash") => parse_runner_tokens_register_hash(rest), + ("runner-tokens", "list") => parse_runner_tokens_list(rest), + ("runner-tokens", "revoke") => parse_runner_tokens_revoke(rest), _ => Err(format!( "unknown admin command: {} {}\n{}", group, diff --git a/crates/webcodex-admin/src/tests.rs b/crates/webcodex-admin/src/tests.rs index 4b52d7f6f..65cbeee4e 100644 --- a/crates/webcodex-admin/src/tests.rs +++ b/crates/webcodex-admin/src/tests.rs @@ -69,6 +69,19 @@ fn admin_usage_keeps_rest_registration_commands_but_not_create_local() { assert!(!stdout.contains("webcodex agent-token register-hash")); } +#[test] +fn admin_parser_rejects_retired_agent_tokens_group_with_migration_guidance() { + assert!(!is_admin_group("agent-tokens")); + for args in [ + vec!["agent-tokens".to_string()], + vec!["agent-tokens".to_string(), "list".to_string()], + ] { + let error = parse_admin_cli(&args).unwrap_err(); + assert!(error.contains("agent-tokens was removed"), "{error}"); + assert!(error.contains("runner-tokens"), "{error}"); + } +} + #[test] fn users_create_builds_request_path_and_body() { let req = request(&[ diff --git a/crates/webcodex-cli/src/lib.rs b/crates/webcodex-cli/src/lib.rs index 4b4f7bdbf..cd94aefdf 100644 --- a/crates/webcodex-cli/src/lib.rs +++ b/crates/webcodex-cli/src/lib.rs @@ -359,7 +359,10 @@ where "agent-token" => cli_parse_error( "`webcodex agent-token` was removed; use `webcodex runner-tokens ...`".to_string(), ), - "runner-tokens" | "agent-tokens" => parse_runner_token_subcommand(&args[1..]), + "agent-tokens" => cli_parse_error( + "`webcodex agent-tokens` was removed; use `webcodex runner-tokens ...`".to_string(), + ), + "runner-tokens" => parse_runner_token_subcommand(&args[1..]), "token" => cli_parse_error( "`webcodex token` was removed; use `webcodex tokens ...`".to_string(), ), @@ -2265,9 +2268,14 @@ fn parse_runner_status_with_identity( "--user-token-file" => { opts.user_token_file = Some(PathBuf::from(next_value(&mut iter, arg)?)) } - "--runner-token-file" | "--agent-token-file" => { + "--runner-token-file" => { opts.runner_token_file = Some(PathBuf::from(next_value(&mut iter, arg)?)) } + "--agent-token-file" => { + return Err( + "--agent-token-file is retired; use --runner-token-file instead".to_string(), + ) + } "--json" => opts.json = true, _ => return Err(format!("unknown runner status flag: {}", arg)), } @@ -2470,9 +2478,14 @@ fn parse_pairing_create(args: &[String]) -> Result .map_err(|_| "--ttl-secs must be an integer".to_string())?; } "--user-token-name" => opts.user_token_name = Some(next_value(&mut iter, arg)?), - "--runner-token-name" | "--agent-token-name" => { + "--runner-token-name" => { opts.runner_token_name = Some(next_value(&mut iter, arg)?) } + "--agent-token-name" => { + return Err( + "--agent-token-name is retired; use --runner-token-name instead".to_string(), + ) + } "--json" => opts.json = true, _ => return Err(format!("unknown pairing create flag: {}", arg)), } @@ -2517,7 +2530,6 @@ fn parse_cli_runner_init(args: &[String]) -> Result { let mut profile: Option = None; let mut output_explicit = false; let mut project_registry_dir_explicit = false; - let mut legacy_projects_dir_explicit = false; let mut iter = args.iter(); while let Some(arg) = iter.next() { match arg.as_str() { @@ -2536,24 +2548,13 @@ fn parse_cli_runner_init(args: &[String]) -> Result { .map_err(|_| "--poll-interval-ms must be an integer".to_string())?; } "--project-registry-dir" => { - if legacy_projects_dir_explicit { - return Err( - "use only one of --project-registry-dir or legacy --projects-dir" - .to_string(), - ); - } opts.project_registry_dir = PathBuf::from(next_value(&mut iter, arg)?); project_registry_dir_explicit = true; } "--projects-dir" => { - if project_registry_dir_explicit { - return Err( - "use only one of --project-registry-dir or legacy --projects-dir" - .to_string(), - ); - } - opts.project_registry_dir = PathBuf::from(next_value(&mut iter, arg)?); - legacy_projects_dir_explicit = true; + return Err( + "--projects-dir is retired; use --project-registry-dir instead".to_string(), + ) } "--allowed-root" => opts .allowed_roots @@ -2578,17 +2579,17 @@ fn parse_cli_runner_init(args: &[String]) -> Result { if !output_explicit { opts.output = client_profile_runner_config(&profile)?; } - if !project_registry_dir_explicit && !legacy_projects_dir_explicit { + if !project_registry_dir_explicit { opts.project_registry_dir = client_profile_project_registry_dir(&profile)?; } } else { if !output_explicit && opts.output.as_os_str().is_empty() { let profile = validate_client_profile(&opts.client_id)?; opts.output = client_profile_runner_config(&profile)?; - if !project_registry_dir_explicit && !legacy_projects_dir_explicit { + if !project_registry_dir_explicit { opts.project_registry_dir = client_profile_project_registry_dir(&profile)?; } - } else if !project_registry_dir_explicit && !legacy_projects_dir_explicit { + } else if !project_registry_dir_explicit { let default = Path::new(DEFAULT_INIT_PROJECT_REGISTRY_DIR); let base = default.parent().ok_or_else(|| { "default Runner project registry path has no parent directory".to_string() diff --git a/crates/webcodex-cli/src/webcodex_cli/connect/disconnect.rs b/crates/webcodex-cli/src/webcodex_cli/connect/disconnect.rs index 3a74b685c..655bd272c 100644 --- a/crates/webcodex-cli/src/webcodex_cli/connect/disconnect.rs +++ b/crates/webcodex-cli/src/webcodex_cli/connect/disconnect.rs @@ -718,7 +718,7 @@ mod tests { } #[tokio::test] - async fn offline_disconnect_accepts_legacy_agent_toml_only() { + async fn offline_disconnect_rejects_retired_agent_toml_only() { let tmp = canonical_test_tempdir(); let config = tmp.path().join("config"); let state = tmp.path().join("state"); @@ -733,7 +733,7 @@ mod tests { ) .unwrap(); - let result = run_disconnect(DisconnectOptions { + let error = run_disconnect(DisconnectOptions { project, profile: Some("legacy".to_string()), config_base: Some(config), @@ -741,10 +741,12 @@ mod tests { server_http: ServerHttpOptions::default(), }) .await - .unwrap(); - assert_eq!(result.outcome, "local_unregistered"); + .unwrap_err(); + assert!(error.contains("retired Runner config"), "{error}"); + assert!(error.contains("rename it to runner.toml"), "{error}"); assert!(profile_dir.join("agent.toml").is_file()); assert!(!profile_dir.join("runner.toml").exists()); + assert!(profile_dir.join("project-registry/repo.toml").is_file()); } #[tokio::test] @@ -774,7 +776,8 @@ mod tests { .unwrap_err(); assert!(error.contains("runner.toml")); assert!(error.contains("agent.toml")); - assert!(error.contains("refusing to guess")); + assert!(error.contains("retired"), "{error}"); + assert!(error.contains("remove or archive"), "{error}"); assert!(profile_dir.join("project-registry/repo.toml").is_file()); } diff --git a/crates/webcodex-cli/src/webcodex_cli/connect/profile.rs b/crates/webcodex-cli/src/webcodex_cli/connect/profile.rs index 191d7c89d..6779f5879 100644 --- a/crates/webcodex-cli/src/webcodex_cli/connect/profile.rs +++ b/crates/webcodex-cli/src/webcodex_cli/connect/profile.rs @@ -641,6 +641,12 @@ pub(super) fn render_runner_document( canonical_project: &Path, ) -> Result { let mut root = read_runner_document(path)?; + if root.contains_key("projects_dir") { + return Err( + "Runner config field 'projects_dir' is retired; use 'project_registry_dir' instead" + .to_string(), + ); + } root.insert( "server_url".to_string(), TomlValue::String(server_url.to_string()), @@ -655,10 +661,6 @@ pub(super) fn render_runner_document( TomlValue::String(client_id.to_string()), ); root.remove("owner"); - // A hosted connect update is also a config-spelling migration. Keeping the - // legacy key while inserting the canonical one would create a Runner config - // that the load-time dual-field fence correctly rejects. - root.remove("projects_dir"); root.insert( "project_registry_dir".to_string(), TomlValue::String(project_registry_dir.to_string_lossy().to_string()), @@ -795,7 +797,7 @@ mod tests { } #[test] - fn omitted_key_is_generated_once_then_recovered_from_matching_legacy_registry_profile() { + fn omitted_key_is_generated_once_then_recovered_from_matching_legacy_registry_layout() { let tmp = tempfile::tempdir().unwrap(); let project = tmp.path().join("project"); std::fs::create_dir(&project).unwrap(); @@ -827,12 +829,11 @@ mod tests { assert!(first.generated); let profile = derived_profile("https://example.test", &first.value); let profile_dir = config_base.join("clients").join(&profile); - // Deliberately exercise a pre-normalization hosted profile. New - // profiles use project-registry/, but a sole projects.d/ remains a - // supported compatibility layout. + // A sole physical projects.d/ registry remains supported even though + // the agent.toml filename and projects_dir config field are retired. std::fs::create_dir_all(profile_dir.join("projects.d")).unwrap(); std::fs::write( - profile_dir.join("agent.toml"), + profile_dir.join("runner.toml"), format!( "server_url = \"https://example.test\"\ntoken = {:?}\nclient_id = \"client\"\n", first.value @@ -852,18 +853,17 @@ mod tests { recovered.recovered_profile.as_deref(), Some(profile.as_str()) ); - assert!(!profile_dir.join("runner.toml").exists()); + assert!(profile_dir.join("runner.toml").is_file()); std::fs::write(profile_dir.join(KEY_DISCLOSED_FILE), "disclosed = true\n").unwrap(); let disclosed = resolve_key(&options, &config_base, "https://example.test", &project).unwrap(); assert!(!disclosed.generated); - std::fs::write(profile_dir.join("runner.toml"), "conflicting = true\n").unwrap(); + std::fs::write(profile_dir.join("agent.toml"), "retired = true\n").unwrap(); let error = resolve_key(&options, &config_base, "https://example.test", &project).unwrap_err(); - assert!(error.contains("runner.toml")); - assert!(error.contains("agent.toml")); - assert!(error.contains("refusing to guess")); + assert!(error.contains("both runner.toml and retired agent.toml"), "{error}"); + assert!(error.contains("remove or archive agent.toml"), "{error}"); } #[cfg(unix)] @@ -958,9 +958,9 @@ mod tests { } #[test] - fn runner_document_migrates_legacy_projects_dir_without_dual_fields() { + fn runner_document_rejects_retired_projects_dir_with_migration_guidance() { let tmp = tempfile::tempdir().unwrap(); - let config = tmp.path().join("agent.toml"); + let config = tmp.path().join("runner.toml"); let project = tmp.path().join("project"); let legacy_registry = tmp.path().join("projects.d"); std::fs::create_dir(&project).unwrap(); @@ -974,7 +974,7 @@ mod tests { ) .unwrap(); - let rendered = render_runner_document( + let error = render_runner_document( &config, "https://example.test", "shared", @@ -982,14 +982,12 @@ mod tests { &legacy_registry, &project.canonicalize().unwrap(), ) - .unwrap(); - let parsed: TomlValue = toml::from_str(&rendered).unwrap(); - assert!(parsed.get("projects_dir").is_none()); - assert_eq!( - parsed["project_registry_dir"].as_str(), - Some(legacy_registry.to_string_lossy().as_ref()) - ); - assert_eq!(parsed["custom_field"].as_str(), Some("preserved")); + .unwrap_err(); + assert!(error.contains("'projects_dir' is retired"), "{error}"); + assert!(error.contains("'project_registry_dir'"), "{error}"); + let unchanged = std::fs::read_to_string(&config).unwrap(); + assert!(unchanged.contains("projects_dir")); + assert!(unchanged.contains("custom_field")); } #[cfg(unix)] diff --git a/crates/webcodex-cli/src/webcodex_cli/project.rs b/crates/webcodex-cli/src/webcodex_cli/project.rs index 2b650f653..d8f413c7c 100644 --- a/crates/webcodex-cli/src/webcodex_cli/project.rs +++ b/crates/webcodex-cli/src/webcodex_cli/project.rs @@ -49,7 +49,7 @@ struct RegistrationRunnerConfig { #[serde(default)] project_registry_dir: Option, #[serde(default, rename = "projects_dir")] - legacy_projects_dir: Option, + removed_projects_dir: Option, #[serde(default)] policy: RegistrationPolicy, } @@ -348,16 +348,15 @@ fn persist_registration_allowed_roots(path: &Path, roots: &[PathBuf]) -> Result< } fn registration_project_registry_dir(config: &RegistrationRunnerConfig) -> Result { - match ( - config.project_registry_dir.as_ref(), - config.legacy_projects_dir.as_ref(), - ) { - (Some(_), Some(_)) => Err( - "project_registry_dir and legacy projects_dir cannot both be configured; keep exactly one Runner project registry setting" + if config.removed_projects_dir.is_some() { + return Err( + "Runner config field 'projects_dir' is retired; use 'project_registry_dir' instead" .to_string(), - ), - (Some(path), None) | (None, Some(path)) => Ok(path.clone()), - (None, None) => { + ); + } + match config.project_registry_dir.as_ref() { + Some(path) => Ok(path.clone()), + None => { let base = webcodex_runner_config::paths::default_client_config_base_dir()?; webcodex_runner_config::paths::select_project_registry_dir(&base) } @@ -1671,7 +1670,7 @@ mod tests { let _guard = crate::webcodex_cli::test_support::env_test_guard(); let config = RegistrationRunnerConfig { project_registry_dir: None, - legacy_projects_dir: None, + removed_projects_dir: None, policy: RegistrationPolicy::default(), }; let base = webcodex_runner_config::paths::default_client_config_base_dir().unwrap(); @@ -1683,25 +1682,27 @@ mod tests { } #[test] - fn registration_config_accepts_legacy_projects_dir_alias() { - let legacy = PathBuf::from("/tmp/legacy-projects.d"); + fn registration_config_rejects_retired_projects_dir() { let config = RegistrationRunnerConfig { project_registry_dir: None, - legacy_projects_dir: Some(legacy.clone()), + removed_projects_dir: Some(toml::Value::String("/tmp/projects.d".to_string())), policy: RegistrationPolicy::default(), }; - assert_eq!(registration_project_registry_dir(&config).unwrap(), legacy); + let error = registration_project_registry_dir(&config).unwrap_err(); + assert!(error.contains("'projects_dir' is retired"), "{error}"); + assert!(error.contains("'project_registry_dir'"), "{error}"); } #[test] - fn registration_config_rejects_both_registry_fields() { + fn registration_config_rejects_retired_projects_dir_with_canonical_field() { let config = RegistrationRunnerConfig { project_registry_dir: Some(PathBuf::from("/tmp/project-registry")), - legacy_projects_dir: Some(PathBuf::from("/tmp/projects.d")), + removed_projects_dir: Some(toml::Value::String("/tmp/projects.d".to_string())), policy: RegistrationPolicy::default(), }; let error = registration_project_registry_dir(&config).unwrap_err(); - assert!(error.contains("cannot both be configured"), "{error}"); + assert!(error.contains("'projects_dir' is retired"), "{error}"); + assert!(error.contains("'project_registry_dir'"), "{error}"); } #[test] diff --git a/crates/webcodex-cli/src/webcodex_cli/runner_service.rs b/crates/webcodex-cli/src/webcodex_cli/runner_service.rs index 12dac8c02..fdd04eea0 100644 --- a/crates/webcodex-cli/src/webcodex_cli/runner_service.rs +++ b/crates/webcodex-cli/src/webcodex_cli/runner_service.rs @@ -244,7 +244,7 @@ struct RunnerStatusConfig { #[serde(default)] project_registry_dir: Option, #[serde(default, rename = "projects_dir")] - legacy_projects_dir: Option, + removed_projects_dir: Option, #[serde(default)] policy: RunnerStatusPolicy, } @@ -273,15 +273,15 @@ fn read_runner_config_metadata(path: &Path) -> Result { - return Err( - "project_registry_dir and legacy projects_dir cannot both be configured; keep exactly one Runner project registry setting" - .to_string(), - ) - } - (Some(path), None) | (None, Some(path)) => path, - (None, None) => { + if cfg.removed_projects_dir.is_some() { + return Err( + "Runner config field 'projects_dir' is retired; use 'project_registry_dir' instead" + .to_string(), + ); + } + let project_registry_dir = match cfg.project_registry_dir { + Some(path) => path, + None => { let base = webcodex_runner_config::paths::default_client_config_base_dir()?; webcodex_runner_config::paths::select_project_registry_dir(&base)? } @@ -818,9 +818,9 @@ mod tests { } #[test] - fn runner_status_metadata_accepts_legacy_registry_alias_and_rejects_both_fields() { + fn runner_status_metadata_rejects_retired_projects_dir() { let tmp = tempfile::tempdir().unwrap(); - let config = tmp.path().join("agent.toml"); + let config = tmp.path().join("runner.toml"); let legacy = tmp.path().join("projects.d"); std::fs::write( &config, @@ -830,8 +830,9 @@ mod tests { ), ) .unwrap(); - let metadata = read_runner_config_metadata(&config).unwrap(); - assert_eq!(metadata.project_registry_dir, legacy); + let error = read_runner_config_metadata(&config).unwrap_err(); + assert!(error.contains("'projects_dir' is retired"), "{error}"); + assert!(error.contains("'project_registry_dir'"), "{error}"); let current = tmp.path().join("project-registry"); std::fs::write( @@ -844,7 +845,8 @@ mod tests { ) .unwrap(); let error = read_runner_config_metadata(&config).unwrap_err(); - assert!(error.contains("cannot both be configured"), "{error}"); + assert!(error.contains("'projects_dir' is retired"), "{error}"); + assert!(error.contains("'project_registry_dir'"), "{error}"); } #[test] diff --git a/crates/webcodex-cli/src/webcodex_cli/tests/profiles.rs b/crates/webcodex-cli/src/webcodex_cli/tests/profiles.rs index 9b48cc796..ef056d7f8 100644 --- a/crates/webcodex-cli/src/webcodex_cli/tests/profiles.rs +++ b/crates/webcodex-cli/src/webcodex_cli/tests/profiles.rs @@ -102,8 +102,8 @@ fn runner_init_explicit_output_and_project_registry_dir_win() { } #[test] -fn runner_init_accepts_legacy_projects_dir_alias() { - let opts = parse_cli_runner_init(&args(&[ +fn runner_init_rejects_retired_projects_dir_alias() { + let error = parse_cli_runner_init(&args(&[ "--server-url", "https://example.test", "--token", @@ -115,13 +115,14 @@ fn runner_init_accepts_legacy_projects_dir_alias() { "--projects-dir", "/tmp/projects.d", ])) - .unwrap(); - assert_eq!(opts.project_registry_dir, PathBuf::from("/tmp/projects.d")); + .unwrap_err(); + assert!(error.contains("--projects-dir is retired"), "{error}"); + assert!(error.contains("--project-registry-dir"), "{error}"); } #[cfg(unix)] #[test] -fn user_scope_runner_config_keeps_legacy_only_and_rejects_dual_names() { +fn user_scope_runner_config_rejects_retired_agent_toml() { let _guard = env_test_guard(); let tmp = tempfile::tempdir().unwrap(); let tmp_path = tmp.path().to_str().unwrap(); @@ -136,19 +137,18 @@ fn user_scope_runner_config_keeps_legacy_only_and_rejects_dual_names() { config_dir.join("runner.toml") ); std::fs::write(config_dir.join("agent.toml"), "legacy = true\n").unwrap(); - assert_eq!( - runner_config_for_scope(ServiceScope::User, None).unwrap(), - config_dir.join("agent.toml") - ); + let error = runner_config_for_scope(ServiceScope::User, None).unwrap_err(); + assert!(error.contains("retired Runner config"), "{error}"); + assert!(error.contains("rename it to runner.toml"), "{error}"); + std::fs::write(config_dir.join("runner.toml"), "current = true\n").unwrap(); let error = runner_config_for_scope(ServiceScope::User, None).unwrap_err(); - assert!(error.contains("runner.toml")); - assert!(error.contains("agent.toml")); - assert!(error.contains("refusing to guess")); + assert!(error.contains("both runner.toml and retired agent.toml"), "{error}"); + assert!(error.contains("remove or archive agent.toml"), "{error}"); } #[test] -fn runner_init_rejects_new_and_legacy_registry_flags_together() { +fn runner_init_rejects_retired_projects_dir_even_with_canonical_flag() { let error = parse_cli_runner_init(&args(&[ "--server-url", "https://example.test", @@ -164,7 +164,8 @@ fn runner_init_rejects_new_and_legacy_registry_flags_together() { "/tmp/projects.d", ])) .unwrap_err(); - assert!(error.contains("use only one"), "{error}"); + assert!(error.contains("--projects-dir is retired"), "{error}"); + assert!(error.contains("--project-registry-dir"), "{error}"); } #[test] @@ -272,18 +273,16 @@ fn runner_status_explicit_paths_win_and_no_profile_uses_canonical_default() { #[cfg(unix)] #[test] -fn legacy_runner_status_agent_token_file_flag_is_an_alias() { - let opts = parse_runner_status(&args(&[ +fn runner_status_rejects_retired_agent_token_file_flag() { + let error = parse_runner_status(&args(&[ "--scope", "system", "--agent-token-file", "/tmp/legacy-runner-token", ])) - .unwrap(); - assert_eq!( - opts.runner_token_file, - Some(PathBuf::from("/tmp/legacy-runner-token")) - ); + .unwrap_err(); + assert!(error.contains("--agent-token-file is retired"), "{error}"); + assert!(error.contains("--runner-token-file"), "{error}"); } /// Unix-only: derives systemd service paths, which require Unix diff --git a/crates/webcodex-cli/src/webcodex_cli/tests/usage.rs b/crates/webcodex-cli/src/webcodex_cli/tests/usage.rs index 343d03bff..0e05cb901 100644 --- a/crates/webcodex-cli/src/webcodex_cli/tests/usage.rs +++ b/crates/webcodex-cli/src/webcodex_cli/tests/usage.rs @@ -348,6 +348,8 @@ fn webcodex_cli_runner_help_mentions_lifecycle_subcommands() { assert!(stdout.contains("Stable Runner client id")); assert!(stdout.contains("Human-readable Runner name")); assert!(stdout.contains("runner.toml")); + assert!(stdout.contains("--project-registry-dir PATH")); + assert!(!stdout.contains("--projects-dir PATH")); } other => panic!("expected Runner init help exit, got {other:?}"), } @@ -563,33 +565,23 @@ fn canonical_plural_admin_actions_dispatch_and_singular_groups_fail_closed() { } #[test] -fn legacy_agent_tokens_alias_uses_canonical_runner_token_implementation() { - assert!(matches!( - cli_action([ - "agent-tokens", - "create-local", - "--server-url", - "https://example.test", - "--username", - "alice", - "--credential", - "wc_acct_example", - "--client-id", - "runner-1", - ]), - CliAction::RunnerTokenCreateLocal(_) - )); - assert!(matches!( - cli_action([ - "agent-tokens", - "list", - "--server-url", - "https://example.test", - "--username", - "alice", - ]), - CliAction::Admin(_) - )); +fn retired_agent_tokens_alias_reports_canonical_replacement() { + for action in ["create-local", "list"] { + match cli_action(["agent-tokens", action]) { + CliAction::Exit { + code, + stdout, + stderr, + } => { + assert_eq!(code, 2); + assert!(stdout.is_empty()); + assert!(stderr.contains("agent-tokens"), "{stderr}"); + assert!(stderr.contains("removed"), "{stderr}"); + assert!(stderr.contains("webcodex runner-tokens"), "{stderr}"); + } + other => panic!("retired agent-tokens alias still dispatched: {other:?}"), + } + } } #[test] @@ -677,6 +669,23 @@ fn removed_user_selection_aliases_are_rejected() { } } +#[test] +fn pairing_create_rejects_retired_agent_token_name_flag() { + match cli_action(["pairing", "create", "--agent-token-name", "legacy"]) { + CliAction::Exit { + code, + stdout, + stderr, + } => { + assert_eq!(code, 2); + assert!(stdout.is_empty()); + assert!(stderr.contains("--agent-token-name is retired"), "{stderr}"); + assert!(stderr.contains("--runner-token-name"), "{stderr}"); + } + other => panic!("retired --agent-token-name still dispatched: {other:?}"), + } +} + #[test] fn pairing_create_help_marks_client_id_optional_and_explains_matching_device() { match cli_action(["pairing", "create", "--help"]) { diff --git a/crates/webcodex-cli/src/webcodex_cli/usage.rs b/crates/webcodex-cli/src/webcodex_cli/usage.rs index 5f4b9e135..4abaf3945 100644 --- a/crates/webcodex-cli/src/webcodex_cli/usage.rs +++ b/crates/webcodex-cli/src/webcodex_cli/usage.rs @@ -481,7 +481,6 @@ pub(crate) fn runner_init_usage() -> &'static str { --transport NAME websocket (default), polling, quic, or auto\n\ --poll-interval-ms N Minimum idle polling interval; default 1000, max 30000 for polling/auto\n\ --project-registry-dir PATH Runner project registry directory [default: profile project-registry]\n\ - --projects-dir PATH Deprecated legacy alias for --project-registry-dir\n\ --allowed-root PATH Allowed project/root path; repeatable\n\ --allow-cwd-anywhere BOOL Allow cwd outside allowed_roots; default false\n\ --output PATH|- Output config path, or '-' for stdout [default: profile runner.toml]\n\ diff --git a/crates/webcodex-runner-config/src/paths.rs b/crates/webcodex-runner-config/src/paths.rs index 2f5382fcf..69bc48425 100644 --- a/crates/webcodex-runner-config/src/paths.rs +++ b/crates/webcodex-runner-config/src/paths.rs @@ -27,7 +27,7 @@ use std::path::{Path, PathBuf}; /// Canonical Runner configuration filename for WebCodex 0.4 and later. pub const RUNNER_CONFIG_FILE: &str = "runner.toml"; -/// Pre-0.4 Runner configuration filename retained as a read compatibility alias. +/// Retired pre-0.4 Runner configuration filename retained for migration diagnostics. pub const LEGACY_AGENT_CONFIG_FILE: &str = "agent.toml"; /// Canonical directory name for newly created Runner project registries. pub const PROJECT_REGISTRY_DIR_NAME: &str = "project-registry"; @@ -44,9 +44,10 @@ fn path_entry_exists(path: &Path) -> Result { /// Resolve an existing Runner config within one authoritative config directory. /// -/// `runner.toml` is canonical. `agent.toml` remains readable only when it is the -/// sole config entry. If both names exist, fail closed rather than choosing a -/// winner and risking split-brain configuration. +/// `runner.toml` is canonical. A retired `agent.toml` is never selected +/// automatically: legacy-only directories receive migration guidance, while +/// directories containing both names continue to fail closed so an operator +/// cannot accidentally keep editing an ignored legacy file. pub fn existing_runner_config_path(dir: &Path) -> Result, String> { let runner = dir.join(RUNNER_CONFIG_FILE); let legacy = dir.join(LEGACY_AGENT_CONFIG_FILE); @@ -54,20 +55,26 @@ pub fn existing_runner_config_path(dir: &Path) -> Result, String let legacy_exists = path_entry_exists(&legacy)?; match (runner_exists, legacy_exists) { (true, true) => Err(format!( - "both {} and {} exist in {}; refusing to guess which Runner config is authoritative", + "both {} and retired {} exist in {}; remove or archive {} before continuing with the canonical Runner config", RUNNER_CONFIG_FILE, LEGACY_AGENT_CONFIG_FILE, - dir.display() + dir.display(), + LEGACY_AGENT_CONFIG_FILE, )), (true, false) => Ok(Some(runner)), - (false, true) => Ok(Some(legacy)), + (false, true) => Err(format!( + "retired Runner config {} exists in {}; rename it to {} before continuing", + LEGACY_AGENT_CONFIG_FILE, + dir.display(), + RUNNER_CONFIG_FILE, + )), (false, false) => Ok(None), } } /// Resolve the Runner config path for one authoritative config directory. -/// Existing legacy-only directories keep using `agent.toml`; a new directory -/// gets the canonical `runner.toml` creation target. +/// Existing directories must use `runner.toml`; a new directory gets the +/// canonical `runner.toml` creation target. pub fn resolve_runner_config_path(dir: &Path) -> Result { Ok(existing_runner_config_path(dir)?.unwrap_or_else(|| dir.join(RUNNER_CONFIG_FILE))) } @@ -568,7 +575,7 @@ mod tests { } #[test] - fn runner_config_path_prefers_canonical_and_keeps_legacy_only_compatibility() { + fn runner_config_path_requires_canonical_filename_and_guides_legacy_only() { let dir = test_temp_dir("compat"); assert_eq!( resolve_runner_config_path(&dir).unwrap(), @@ -576,10 +583,10 @@ mod tests { ); std::fs::write(dir.join(LEGACY_AGENT_CONFIG_FILE), "legacy").unwrap(); - assert_eq!( - resolve_runner_config_path(&dir).unwrap(), - dir.join(LEGACY_AGENT_CONFIG_FILE) - ); + let error = resolve_runner_config_path(&dir).unwrap_err(); + assert!(error.contains("retired Runner config")); + assert!(error.contains(LEGACY_AGENT_CONFIG_FILE)); + assert!(error.contains(RUNNER_CONFIG_FILE)); std::fs::remove_file(dir.join(LEGACY_AGENT_CONFIG_FILE)).unwrap(); std::fs::write(dir.join(RUNNER_CONFIG_FILE), "current").unwrap(); @@ -591,14 +598,15 @@ mod tests { } #[test] - fn runner_config_path_fails_closed_when_both_names_exist() { + fn runner_config_path_fails_closed_when_retired_and_canonical_names_both_exist() { let dir = test_temp_dir("dual"); std::fs::write(dir.join(RUNNER_CONFIG_FILE), "current").unwrap(); std::fs::write(dir.join(LEGACY_AGENT_CONFIG_FILE), "legacy").unwrap(); let error = resolve_runner_config_path(&dir).unwrap_err(); assert!(error.contains(RUNNER_CONFIG_FILE)); assert!(error.contains(LEGACY_AGENT_CONFIG_FILE)); - assert!(error.contains("refusing to guess")); + assert!(error.contains("retired")); + assert!(error.contains("remove or archive")); std::fs::remove_dir_all(dir).unwrap(); } diff --git a/crates/webcodex-runner/src/main.rs b/crates/webcodex-runner/src/main.rs index 443db5b29..390af0f53 100644 --- a/crates/webcodex-runner/src/main.rs +++ b/crates/webcodex-runner/src/main.rs @@ -129,7 +129,6 @@ fn usage() -> &'static str { --config overrides the profile-derived default.\n\n\ Environment:\n\ WEBCODEX_RUNNER_CONFIG default config path override\n\ - WEBCODEX_AGENT_CONFIG legacy alias for WEBCODEX_RUNNER_CONFIG\n\ Example runner.toml:\n\ server_url = \"https://v4.yyjeqhc.cn\"\n\ token = \"...\"\n\ @@ -228,14 +227,13 @@ where if let Some(profile) = profile { client_profile_runner_config(&profile)? } else { - if runner_config_env.is_some() && legacy_agent_config_env.is_some() { + if legacy_agent_config_env.is_some() { return Err( - "WEBCODEX_RUNNER_CONFIG and legacy WEBCODEX_AGENT_CONFIG cannot both be set" + "WEBCODEX_AGENT_CONFIG is retired; use WEBCODEX_RUNNER_CONFIG instead" .to_string(), ); } runner_config_env - .or(legacy_agent_config_env) .map(PathBuf::from) .map(Ok) .unwrap_or_else(default_config_path)? diff --git a/crates/webcodex-runner/src/main_tests.rs b/crates/webcodex-runner/src/main_tests.rs index cb1515bfb..aa22db5cb 100644 --- a/crates/webcodex-runner/src/main_tests.rs +++ b/crates/webcodex-runner/src/main_tests.rs @@ -79,7 +79,7 @@ fn test_config(project_registry_dir: PathBuf) -> RunnerConfig { hostname: None, host_context: None, project_registry_dir: Some(project_registry_dir), - legacy_projects_dir: None, + removed_projects_dir: None, poll_interval_ms: 1000, capabilities: None, max_concurrent_jobs: None, diff --git a/crates/webcodex-runner/src/main_tests/runner_config.rs b/crates/webcodex-runner/src/main_tests/runner_config.rs index a238d77e7..d65d808b0 100644 --- a/crates/webcodex-runner/src/main_tests/runner_config.rs +++ b/crates/webcodex-runner/src/main_tests/runner_config.rs @@ -1,10 +1,9 @@ use super::*; -use crate::webcodex_runner::config::restart_required_fields; #[test] -fn runner_config_accepts_legacy_projects_dir_alias_and_normalizes_it() { +fn runner_config_rejects_retired_projects_dir_with_migration_guidance() { let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("agent.toml"); + let path = tmp.path().join("runner.toml"); let registry = tmp.path().join("projects.d"); std::fs::write( &path, @@ -14,41 +13,36 @@ fn runner_config_accepts_legacy_projects_dir_alias_and_normalizes_it() { ), ) .unwrap(); - let cfg = load_config(&path).unwrap(); - assert_eq!( - cfg.project_registry_dir.as_deref(), - Some(registry.as_path()) - ); - assert!(cfg.legacy_projects_dir.is_none()); + let error = load_config(&path).unwrap_err(); + assert!(error.contains("'projects_dir' is retired"), "{error}"); + assert!(error.contains("'project_registry_dir'"), "{error}"); } #[test] -fn runner_config_alias_only_migration_does_not_require_restart() { +fn runner_config_accepts_canonical_project_registry_dir() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("runner.toml"); let registry = tmp.path().join("projects.d"); - let render = |field: &str| { + std::fs::write( + &path, format!( - "server_url = \"http://127.0.0.1:8000\"\ntoken = \"t\"\nclient_id = \"oe\"\n{field} = {:?}\n[policy]\nallow_cwd_anywhere = true\n", + "server_url = \"http://127.0.0.1:8000\"\ntoken = \"t\"\nclient_id = \"oe\"\nproject_registry_dir = {:?}\n[policy]\nallow_cwd_anywhere = true\n", registry.to_string_lossy() - ) - }; - - std::fs::write(&path, render("projects_dir")).unwrap(); - let legacy = load_config(&path).unwrap(); - std::fs::write(&path, render("project_registry_dir")).unwrap(); - let canonical = load_config(&path).unwrap(); - - assert!(legacy.legacy_projects_dir.is_none()); - assert!(canonical.legacy_projects_dir.is_none()); - assert_eq!(legacy.project_registry_dir, canonical.project_registry_dir); - assert!(restart_required_fields(&legacy, &canonical).is_empty()); + ), + ) + .unwrap(); + let cfg = load_config(&path).unwrap(); + assert_eq!( + cfg.project_registry_dir.as_deref(), + Some(registry.as_path()) + ); + assert!(cfg.removed_projects_dir.is_none()); } #[test] -fn runner_config_rejects_new_and_legacy_registry_fields_together() { +fn runner_config_rejects_retired_projects_dir_even_with_canonical_field() { let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("agent.toml"); + let path = tmp.path().join("runner.toml"); let current = tmp.path().join("project-registry"); let legacy = tmp.path().join("projects.d"); std::fs::write( @@ -61,7 +55,8 @@ fn runner_config_rejects_new_and_legacy_registry_fields_together() { ) .unwrap(); let error = load_config(&path).unwrap_err(); - assert!(error.contains("cannot both be configured"), "{error}"); + assert!(error.contains("'projects_dir' is retired"), "{error}"); + assert!(error.contains("'project_registry_dir'"), "{error}"); } #[test] @@ -454,7 +449,7 @@ fn runner_version_output_includes_build_metadata() { } #[test] -fn runner_cli_legacy_runtime_args_are_preserved() { +fn runner_cli_explicit_config_path_remains_exact() { let _guard = test_env_lock(); let action = parse_runner_args(["--config", "/tmp/agent.toml", "--once"]).unwrap(); assert_eq!( @@ -483,7 +478,7 @@ fn runner_parent_liveness_is_explicit_opt_in() { } #[test] -fn runner_cli_config_env_prefers_runner_name_and_keeps_legacy_alias_fail_closed() { +fn runner_cli_config_env_rejects_retired_alias_when_defaults_are_consulted() { let _guard = test_env_lock(); let _env = EnvGuard::new() .set("WEBCODEX_RUNNER_CONFIG", "/tmp/runner.toml") @@ -501,21 +496,16 @@ fn runner_cli_config_env_prefers_runner_name_and_keeps_legacy_alias_fail_closed( let _legacy = EnvGuard::new() .remove("WEBCODEX_RUNNER_CONFIG") .set("WEBCODEX_AGENT_CONFIG", "/tmp/agent.toml"); - assert_eq!( - parse_runner_args(std::iter::empty::<&str>()).unwrap(), - RunnerCliAction::Run { - config_path: PathBuf::from("/tmp/agent.toml"), - once: false, - stop_on_stdin_eof: false, - } - ); + let error = parse_runner_args(std::iter::empty::<&str>()).unwrap_err(); + assert!(error.contains("WEBCODEX_AGENT_CONFIG is retired"), "{error}"); + assert!(error.contains("WEBCODEX_RUNNER_CONFIG"), "{error}"); drop(_legacy); - let _ambiguous = EnvGuard::new() + let _both = EnvGuard::new() .set("WEBCODEX_RUNNER_CONFIG", "/tmp/runner.toml") .set("WEBCODEX_AGENT_CONFIG", "/tmp/agent.toml"); let error = parse_runner_args(std::iter::empty::<&str>()).unwrap_err(); - assert!(error.contains("cannot both be set")); + assert!(error.contains("WEBCODEX_AGENT_CONFIG is retired"), "{error}"); assert_eq!( parse_runner_args(["--config", "/tmp/explicit.toml"]).unwrap(), @@ -524,7 +514,7 @@ fn runner_cli_config_env_prefers_runner_name_and_keeps_legacy_alias_fail_closed( once: false, stop_on_stdin_eof: false, }, - "an explicit --config path must not be blocked by conflicting default-path env aliases" + "an explicit --config path must not be blocked by irrelevant default-path env values" ); assert_eq!( parse_runner_args(["--profile", "special"]).unwrap(), @@ -533,12 +523,12 @@ fn runner_cli_config_env_prefers_runner_name_and_keeps_legacy_alias_fail_closed( once: false, stop_on_stdin_eof: false, }, - "an explicit profile must not be blocked by conflicting default-path env aliases" + "an explicit profile must not be blocked by irrelevant default-path env values" ); } #[test] -fn runner_profile_config_resolution_accepts_legacy_only_and_rejects_dual_files() { +fn runner_profile_config_resolution_rejects_retired_agent_toml() { let _guard = test_env_lock(); let tmp = tempfile::tempdir().unwrap(); let _env = EnvGuard::new() @@ -555,13 +545,14 @@ fn runner_profile_config_resolution_accepts_legacy_only_and_rejects_dual_files() profile_dir.join("runner.toml") ); std::fs::write(profile_dir.join("agent.toml"), "legacy").unwrap(); - assert_eq!( - client_profile_runner_config("special").unwrap(), - profile_dir.join("agent.toml") - ); + let error = client_profile_runner_config("special").unwrap_err(); + assert!(error.contains("retired Runner config"), "{error}"); + assert!(error.contains("rename it to runner.toml"), "{error}"); + std::fs::write(profile_dir.join("runner.toml"), "current").unwrap(); let error = client_profile_runner_config("special").unwrap_err(); - assert!(error.contains("refusing to guess")); + assert!(error.contains("both runner.toml and retired agent.toml"), "{error}"); + assert!(error.contains("remove or archive agent.toml"), "{error}"); } #[test] diff --git a/crates/webcodex-runner/src/webcodex_runner/config.rs b/crates/webcodex-runner/src/webcodex_runner/config.rs index 61e05d61d..2f4a25367 100644 --- a/crates/webcodex-runner/src/webcodex_runner/config.rs +++ b/crates/webcodex-runner/src/webcodex_runner/config.rs @@ -75,11 +75,10 @@ pub(crate) struct RunnerConfig { pub(crate) host_context: Option, #[serde(default)] pub(crate) project_registry_dir: Option, - /// Legacy config spelling retained only for load-time compatibility. A - /// loaded config is normalized into `project_registry_dir` and clears this - /// field so runtime comparisons operate on one effective registry path. + /// Retired config spelling retained only as a deserialization trap so old + /// configs fail with explicit migration guidance instead of being ignored. #[serde(default, rename = "projects_dir")] - pub(crate) legacy_projects_dir: Option, + pub(crate) removed_projects_dir: Option, /// Minimum delay after an empty polling response. Repeated idle polls back /// off through the built-in schedule while never going below this value. #[serde(default = "default_poll_interval_ms")] @@ -1094,7 +1093,7 @@ pub(crate) fn restart_required_fields( macro_rules! classify { ($($field:ident),+ $(,)?) => {{ let RunnerConfig { - policy: _, shell: _, skills: _, instructions: _, ssh: _, plugins: _, tool_providers: _, mcp_gateway: _, legacy_projects_dir: _, + policy: _, shell: _, skills: _, instructions: _, ssh: _, plugins: _, tool_providers: _, mcp_gateway: _, removed_projects_dir: _, $($field: _),+ } = candidate; [$((stringify!($field), startup.$field != candidate.$field)),+] @@ -1535,6 +1534,12 @@ pub(crate) fn load_config(path: &Path) -> Result { .map_err(|e| format!("failed to parse config {}: {}", path.display(), e))?; let mut cfg: RunnerConfig = toml::from_str(&content) .map_err(|e| format!("failed to parse config {}: {}", path.display(), e))?; + if cfg.removed_projects_dir.is_some() { + return Err( + "Runner config field 'projects_dir' is retired; use 'project_registry_dir' instead" + .to_string(), + ); + } if cfg.server_url.trim().is_empty() { return Err("server_url cannot be empty".to_string()); } @@ -1584,23 +1589,11 @@ pub(crate) fn load_config(path: &Path) -> Result { let effective = effective_allowed_roots(&cfg.policy.allowed_roots, cfg.policy.allow_cwd_anywhere)?; cfg.policy.allowed_roots = effective; - // Normalize old/new config spellings into one effective registry path. Two - // explicit fields are ambiguous and fail closed rather than guessing - // precedence. With neither field configured, select the on-disk layout - // using the shared four-state compatibility contract. - cfg.project_registry_dir = match ( - cfg.project_registry_dir.take(), - cfg.legacy_projects_dir.take(), - ) { - (Some(_), Some(_)) => { - return Err( - "project_registry_dir and legacy projects_dir cannot both be configured; keep exactly one Runner project registry setting" - .to_string(), - ); - } - (Some(path), None) | (None, Some(path)) => Some(path), - (None, None) => Some(default_project_registry_dir()?), - }; + if cfg.project_registry_dir.is_none() { + // Keep the physical legacy `projects.d/` layout readable in place; + // issue #578 retires the config spelling, not existing registry data. + cfg.project_registry_dir = Some(default_project_registry_dir()?); + } validate_shell_config(&cfg.shell)?; validate_ssh_config(&mut cfg.ssh)?; if let Some(quic) = &cfg.quic { diff --git a/crates/webcodex-runner/src/webcodex_runner/plugin_check_tests.rs b/crates/webcodex-runner/src/webcodex_runner/plugin_check_tests.rs index 7986fda68..b965cb667 100644 --- a/crates/webcodex-runner/src/webcodex_runner/plugin_check_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/plugin_check_tests.rs @@ -628,7 +628,7 @@ fn runner_config( hostname: None, host_context: None, project_registry_dir: Some(project_registry_dir.to_path_buf()), - legacy_projects_dir: None, + removed_projects_dir: None, poll_interval_ms: 1000, capabilities: None, max_concurrent_jobs: None, diff --git a/crates/webcodex-runner/src/webcodex_runner/plugin_tests.rs b/crates/webcodex-runner/src/webcodex_runner/plugin_tests.rs index e0bcf59ab..26c12d702 100644 --- a/crates/webcodex-runner/src/webcodex_runner/plugin_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/plugin_tests.rs @@ -198,7 +198,7 @@ fn runner_config( hostname: None, host_context: None, project_registry_dir: Some(project_registry_dir.to_path_buf()), - legacy_projects_dir: None, + removed_projects_dir: None, poll_interval_ms: 1000, capabilities: None, max_concurrent_jobs: None, diff --git a/crates/webcodex-runner/src/webcodex_runner/transport_tests.rs b/crates/webcodex-runner/src/webcodex_runner/transport_tests.rs index 49df31a1e..a2d994252 100644 --- a/crates/webcodex-runner/src/webcodex_runner/transport_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/transport_tests.rs @@ -241,7 +241,7 @@ fn test_runner_config(server_url: String) -> RunnerConfig { hostname: Some("oe-host".to_string()), host_context: None, project_registry_dir: None, - legacy_projects_dir: None, + removed_projects_dir: None, poll_interval_ms: 10, capabilities: Some(RunnerCapabilities { git: true, diff --git a/docs/RUNNER.md b/docs/RUNNER.md index c742b44d9..feff54ad2 100644 --- a/docs/RUNNER.md +++ b/docs/RUNNER.md @@ -37,9 +37,9 @@ shell state. Some compatibility-facing values still use the historical word `agent`, including the `wc_agent_*` Runner-token prefix and `agent::` runtime Project address. They do not refer to WebCodex's separate Durable Agent domain, and ordinary users do not need the process-level lease identifiers behind Runner recovery. -### Runner config filename compatibility +### Runner config filename migration -`runner.toml` is the canonical config filename. A legacy directory containing only `agent.toml` remains readable for compatibility; if both names exist in the same config directory WebCodex fails closed and asks the operator to resolve the ambiguity. `WEBCODEX_RUNNER_CONFIG` is the current path override; the old `WEBCODEX_AGENT_CONFIG` remains a compatibility alias. +`runner.toml` is the canonical config filename. Automatic/default/profile discovery no longer loads the retired `agent.toml`: a legacy-only directory fails with guidance to rename it to `runner.toml`, while a directory containing both names fails closed until `agent.toml` is removed or archived. An explicit `--config PATH` remains exact and may point to any operator-chosen filename. `WEBCODEX_RUNNER_CONFIG` is the supported path override; `WEBCODEX_AGENT_CONFIG` is retired and fails with migration guidance when default environment resolution is used. ## Connecting to the Server @@ -88,11 +88,13 @@ allow_patch = true `id` and `path` are the important fields; `kind` is optional descriptive metadata. The registry directory is storage for Project records, not a workspace root. -New configurations use `project-registry/` and `project_registry_dir`. A legacy -installation that has only `projects.d/` / `projects_dir` remains readable. If -both old and new locations/fields are configured, WebCodex fails closed instead -of merging or guessing precedence. Use `--project-registry-dir` in new CLI -commands. +New configurations use `project-registry/` and `project_registry_dir`. An +existing installation whose only physical registry directory is `projects.d/` +continues to use that directory in place, but the old `projects_dir` config key +and `--projects-dir` CLI flag are retired and fail with migration guidance. If +both physical registry directories exist, WebCodex still fails closed instead +of merging or guessing precedence. Use `--project-registry-dir` for explicit +CLI selection. Runtime Project ids take the canonical shape `agent::`, for example `agent:workstation:my-repo`. That canonical identity remains the authorization, persistence, audit, Runner-routing, diagnostic, API and CLI address. Model-facing bootstrap/discovery may additionally return a short Server-issued `project_ref` such as `~p1`. Models should normally reuse that selector on later Project-scoped tool calls instead of copying the canonical id. The mapping is durable and scoped to the authenticated caller, is pinned to the canonical id plus Runner-reported Project root identity, and grants no authority: every use re-runs current Project visibility/authorization. It never depends on Workflow Session, ClientWindow, MCP session, transport connection, recent activity or hidden Host state, and a stale ref is never silently rebound to another Project. diff --git a/docs/RUNNER.zh-CN.md b/docs/RUNNER.zh-CN.md index db15dc1a0..dd1e93d7a 100644 --- a/docs/RUNNER.zh-CN.md +++ b/docs/RUNNER.zh-CN.md @@ -31,9 +31,9 @@ Runner 是最接近你仓库的信任边界。请用窄的 allowed roots 与显 部分 compatibility-facing value 仍使用历史 `agent` 名称,例如 Runner token 的 `wc_agent_*` 前缀与 `agent::` runtime Project address。它们不属于 WebCodex 独立的 Durable Agent domain;普通用户也不需要理解 Runner recovery 背后的进程级 lease identifier。 -### Runner 配置文件名兼容 +### Runner 配置文件名迁移 -`runner.toml` 是 canonical config filename。只有旧 `agent.toml` 的历史目录仍可继续读取;同一配置目录同时存在两种文件名时 WebCodex 会 fail closed,要求 operator 先消除歧义。`WEBCODEX_RUNNER_CONFIG` 是当前 path override,旧 `WEBCODEX_AGENT_CONFIG` 只作为兼容 alias 保留。 +`runner.toml` 是 canonical config filename。自动/default/profile discovery 不再加载已退役的 `agent.toml`:目录中只有旧文件时会直接报错并提示重命名为 `runner.toml`;两种文件名同时存在时继续 fail closed,直到 operator 删除或归档 `agent.toml`。显式 `--config PATH` 仍保持精确路径语义,可以指向 operator 自己选择的任意文件名。`WEBCODEX_RUNNER_CONFIG` 是受支持的 path override;使用默认环境变量解析时,已退役的 `WEBCODEX_AGENT_CONFIG` 会返回明确迁移提示。 ## 连接 Server @@ -80,10 +80,11 @@ allow_patch = true 真正重要的是 `id` 与 `path`;`kind` 只属于可选描述 metadata。Registry directory 用于保存 Project record,本身不是 workspace root。 -新配置使用 `project-registry/` 与 `project_registry_dir`。历史安装如果只有 -`projects.d/` / `projects_dir` 仍可读取;如果新旧 location/field 同时存在,WebCodex -会 fail closed,而不是 merge 或猜 precedence。新的 CLI 命令使用 -`--project-registry-dir`。 +新配置使用 `project-registry/` 与 `project_registry_dir`。历史安装如果唯一存在的 +物理 registry directory 是 `projects.d/`,仍会原地继续使用该目录;但旧 +`projects_dir` 配置字段与 `--projects-dir` CLI flag 已退役,出现时会返回迁移提示。 +如果两个物理 registry directory 同时存在,WebCodex 仍会 fail closed,而不是 +merge 或猜 precedence。显式 CLI 选择使用 `--project-registry-dir`。 Runtime Project 的 canonical id 仍形如 `agent::`,例如 `agent:workstation:my-repo`。该 canonical identity 继续用于 authorization、persistence、audit、Runner routing、diagnostic、API 与 CLI 显式 addressing。Model-facing bootstrap/discovery 还可以返回很短的 Server-issued `project_ref`(例如 `~p1`);后续 Project-scoped tool call 应优先复用它,而不是反复复制 canonical id。映射由 Server 持久维护并按 authenticated caller 隔离,同时钉住 canonical id 与 Runner 报告的 Project root identity;它不是 credential/capability,每次使用都会重新执行当前 Project visibility/authorization。该 ref 不依赖 Workflow Session、ClientWindow、MCP session、transport connection、recent activity 或 Host hidden state;失效 ref 绝不会静默重绑到另一个 Project。 diff --git a/docs/agent/architecture-decisions.md b/docs/agent/architecture-decisions.md index 445e6e371..a772c8e58 100644 --- a/docs/agent/architecture-decisions.md +++ b/docs/agent/architecture-decisions.md @@ -430,28 +430,28 @@ the explicit `WEBCODEX_MCP_TEXT_JSON_COMPAT=true` compatibility projection to mirror that same canonical JSON into standard text content without changing the source of truth. -The product concept and public lifecycle namespace are **Runner**. Before the -`v0.4.0` compatibility floor, the local primary config filename is normalized -from `agent.toml` to `runner.toml`. The compatibility contract is intentionally -narrow and deterministic: a config directory containing only legacy -`agent.toml` continues to use that file; one containing only `runner.toml` uses -the canonical file; a directory containing both fails closed rather than -choosing a winner; a directory containing neither creates/targets -`runner.toml`. Explicit `--config PATH` remains exact and does not inspect a -sibling filename. Explicit `--profile` similarly selects its authoritative -profile directory before environment defaults are considered. -`WEBCODEX_RUNNER_CONFIG` is the canonical default-path env override, while -`WEBCODEX_AGENT_CONFIG` remains a legacy alias; setting both is an error only -when environment defaults are actually consulted. - -The same pre-`v0.4.0` normalization applies to the Runner-owned project -registry: `project_registry_dir` and `project-registry/` are canonical for new -state, while a sole legacy `projects_dir` field or `projects.d/` directory may -continue to identify existing state in place. New and legacy fields together, -or both default directory names together, fail closed; WebCodex does not merge, -copy, rename, or choose between two registries implicitly. The registry remains -a directory of Runner-owned project registration records, not a second workspace -or project-root abstraction. +The product concept and public lifecycle namespace are **Runner**. The local +primary config filename is `runner.toml`; automatic/default/profile discovery no +longer reads the retired pre-0.4 `agent.toml`. A legacy-only config directory +fails with migration guidance to rename the file, while a directory containing +both names continues to fail closed so a stale legacy file cannot silently look +authoritative. A directory containing neither creates/targets `runner.toml`. +Explicit `--config PATH` remains exact and does not reinterpret the chosen +filename. Explicit `--profile` similarly selects its authoritative profile +directory before environment defaults are considered. `WEBCODEX_RUNNER_CONFIG` +is the canonical default-path env override; `WEBCODEX_AGENT_CONFIG` is retired +and fails with migration guidance only when environment defaults are actually +consulted. + +Runner-owned project registries use `project_registry_dir` and +`project-registry/` for new state. The old `projects_dir` config spelling and +`--projects-dir` CLI spelling are retired and rejected with migration guidance. +The physical legacy `projects.d/` directory remains readable in place when it is +the sole default registry layout so upgrading does not require an implicit data +move. If both default directory names exist WebCodex fails closed; it does not +merge, copy, rename, or choose between two registries implicitly. The registry +remains a directory of Runner-owned project registration records, not a second +workspace or project-root abstraction. Project registration provenance is also normalized before the `v0.4.0` floor. The generic project-record `kind` field remains open project metadata, while the diff --git a/src/project_entry_setup.rs b/src/project_entry_setup.rs index 50cfad854..956a68877 100644 --- a/src/project_entry_setup.rs +++ b/src/project_entry_setup.rs @@ -599,6 +599,13 @@ pub(super) fn validate_existing_runner( return Ok(()); } let value: toml::Value = read_toml(&runner_config)?; + if value.get("projects_dir").is_some() { + return Err(ProductError::new( + "project_registration_invalid", + "existing Runner configuration uses retired field 'projects_dir'; use 'project_registry_dir' instead", + Some("Rename the Runner config field to 'project_registry_dir', then retry setup."), + )); + } let expected = [ ("server_url", config.server_url()), ("client_id", config.executor_client_id.clone()), @@ -617,14 +624,8 @@ pub(super) fn validate_existing_runner( let current_registry = value .get("project_registry_dir") .and_then(toml::Value::as_str); - let legacy_registry = value.get("projects_dir").and_then(toml::Value::as_str); - if current_registry.is_some() && legacy_registry.is_some() { - return Err(invalid_registration( - "existing Runner configuration contains both project_registry_dir and legacy projects_dir", - )); - } let expected_registry = paths.project_registry.to_string_lossy(); - if current_registry.or(legacy_registry) != Some(expected_registry.as_ref()) { + if current_registry != Some(expected_registry.as_ref()) { return Err(ProductError::new( "project_registration_invalid", "existing Runner configuration conflicts in field 'project_registry_dir'", diff --git a/src/project_entry_tests.rs b/src/project_entry_tests.rs index 224c360ba..1b930f381 100644 --- a/src/project_entry_tests.rs +++ b/src/project_entry_tests.rs @@ -409,7 +409,7 @@ fn setup_repairs_only_missing_components_and_preserves_existing_config() { } #[test] -fn setup_accepts_legacy_agent_toml_only_without_creating_runner_toml() { +fn setup_rejects_retired_agent_toml_only_with_migration_guidance() { let (_temp, root, state) = repo("legacy-runner-config"); let options = options(root, state.clone()); setup(&options).unwrap(); @@ -418,8 +418,10 @@ fn setup_accepts_legacy_agent_toml_only_without_creating_runner_toml() { let original = fs::read(&runner_config).unwrap(); fs::rename(&runner_config, &legacy_config).unwrap(); - let report = setup(&options).unwrap(); - assert_eq!(report.status, "already_configured"); + let error = setup(&options).unwrap_err(); + assert_eq!(error.code, "project_registration_invalid"); + assert!(error.message.contains("retired Runner config"), "{}", error.message); + assert!(error.message.contains("rename it to runner.toml"), "{}", error.message); assert_eq!(fs::read(&legacy_config).unwrap(), original); assert!(!runner_config.exists()); } @@ -437,7 +439,30 @@ fn setup_rejects_dual_runner_config_names_without_guessing() { assert_eq!(error.code, "project_registration_invalid"); assert!(error.message.contains("runner.toml")); assert!(error.message.contains("agent.toml")); - assert!(error.message.contains("refusing to guess")); + assert!(error.message.contains("retired")); + assert!(error.message.contains("remove or archive")); +} + +#[test] +fn setup_rejects_retired_projects_dir_without_rewriting_config() { + let (_temp, root, state) = repo("retired-projects-dir"); + let options = options(root, state.clone()); + setup(&options).unwrap(); + let runner_config = state.join("agent/runner.toml"); + let canonical = fs::read_to_string(&runner_config).unwrap(); + let retired = canonical.replace("project_registry_dir", "projects_dir"); + assert_ne!(retired, canonical, "fixture must contain project_registry_dir"); + fs::write(&runner_config, &retired).unwrap(); + + let error = setup(&options).unwrap_err(); + assert_eq!(error.code, "project_registration_invalid"); + assert!(error.message.contains("'projects_dir'"), "{}", error.message); + assert!( + error.message.contains("'project_registry_dir'"), + "{}", + error.message + ); + assert_eq!(fs::read_to_string(&runner_config).unwrap(), retired); } #[test] From 4cb72a5e9202c0f3c3b668f89e8ccbad0a63acda Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:26:02 +0000 Subject: [PATCH 2/2] style: match rustfmt output --- crates/webcodex-cli/src/lib.rs | 4 +--- .../src/webcodex_cli/connect/profile.rs | 5 +++- .../src/webcodex_cli/tests/profiles.rs | 5 +++- .../src/main_tests/runner_config.rs | 15 +++++++++--- src/project_entry_tests.rs | 23 +++++++++++++++---- 5 files changed, 40 insertions(+), 12 deletions(-) diff --git a/crates/webcodex-cli/src/lib.rs b/crates/webcodex-cli/src/lib.rs index cd94aefdf..9013d59f8 100644 --- a/crates/webcodex-cli/src/lib.rs +++ b/crates/webcodex-cli/src/lib.rs @@ -2478,9 +2478,7 @@ fn parse_pairing_create(args: &[String]) -> Result .map_err(|_| "--ttl-secs must be an integer".to_string())?; } "--user-token-name" => opts.user_token_name = Some(next_value(&mut iter, arg)?), - "--runner-token-name" => { - opts.runner_token_name = Some(next_value(&mut iter, arg)?) - } + "--runner-token-name" => opts.runner_token_name = Some(next_value(&mut iter, arg)?), "--agent-token-name" => { return Err( "--agent-token-name is retired; use --runner-token-name instead".to_string(), diff --git a/crates/webcodex-cli/src/webcodex_cli/connect/profile.rs b/crates/webcodex-cli/src/webcodex_cli/connect/profile.rs index 6779f5879..90b734168 100644 --- a/crates/webcodex-cli/src/webcodex_cli/connect/profile.rs +++ b/crates/webcodex-cli/src/webcodex_cli/connect/profile.rs @@ -862,7 +862,10 @@ mod tests { std::fs::write(profile_dir.join("agent.toml"), "retired = true\n").unwrap(); let error = resolve_key(&options, &config_base, "https://example.test", &project).unwrap_err(); - assert!(error.contains("both runner.toml and retired agent.toml"), "{error}"); + assert!( + error.contains("both runner.toml and retired agent.toml"), + "{error}" + ); assert!(error.contains("remove or archive agent.toml"), "{error}"); } diff --git a/crates/webcodex-cli/src/webcodex_cli/tests/profiles.rs b/crates/webcodex-cli/src/webcodex_cli/tests/profiles.rs index ef056d7f8..168ff2290 100644 --- a/crates/webcodex-cli/src/webcodex_cli/tests/profiles.rs +++ b/crates/webcodex-cli/src/webcodex_cli/tests/profiles.rs @@ -143,7 +143,10 @@ fn user_scope_runner_config_rejects_retired_agent_toml() { std::fs::write(config_dir.join("runner.toml"), "current = true\n").unwrap(); let error = runner_config_for_scope(ServiceScope::User, None).unwrap_err(); - assert!(error.contains("both runner.toml and retired agent.toml"), "{error}"); + assert!( + error.contains("both runner.toml and retired agent.toml"), + "{error}" + ); assert!(error.contains("remove or archive agent.toml"), "{error}"); } diff --git a/crates/webcodex-runner/src/main_tests/runner_config.rs b/crates/webcodex-runner/src/main_tests/runner_config.rs index d65d808b0..156872372 100644 --- a/crates/webcodex-runner/src/main_tests/runner_config.rs +++ b/crates/webcodex-runner/src/main_tests/runner_config.rs @@ -497,7 +497,10 @@ fn runner_cli_config_env_rejects_retired_alias_when_defaults_are_consulted() { .remove("WEBCODEX_RUNNER_CONFIG") .set("WEBCODEX_AGENT_CONFIG", "/tmp/agent.toml"); let error = parse_runner_args(std::iter::empty::<&str>()).unwrap_err(); - assert!(error.contains("WEBCODEX_AGENT_CONFIG is retired"), "{error}"); + assert!( + error.contains("WEBCODEX_AGENT_CONFIG is retired"), + "{error}" + ); assert!(error.contains("WEBCODEX_RUNNER_CONFIG"), "{error}"); drop(_legacy); @@ -505,7 +508,10 @@ fn runner_cli_config_env_rejects_retired_alias_when_defaults_are_consulted() { .set("WEBCODEX_RUNNER_CONFIG", "/tmp/runner.toml") .set("WEBCODEX_AGENT_CONFIG", "/tmp/agent.toml"); let error = parse_runner_args(std::iter::empty::<&str>()).unwrap_err(); - assert!(error.contains("WEBCODEX_AGENT_CONFIG is retired"), "{error}"); + assert!( + error.contains("WEBCODEX_AGENT_CONFIG is retired"), + "{error}" + ); assert_eq!( parse_runner_args(["--config", "/tmp/explicit.toml"]).unwrap(), @@ -551,7 +557,10 @@ fn runner_profile_config_resolution_rejects_retired_agent_toml() { std::fs::write(profile_dir.join("runner.toml"), "current").unwrap(); let error = client_profile_runner_config("special").unwrap_err(); - assert!(error.contains("both runner.toml and retired agent.toml"), "{error}"); + assert!( + error.contains("both runner.toml and retired agent.toml"), + "{error}" + ); assert!(error.contains("remove or archive agent.toml"), "{error}"); } diff --git a/src/project_entry_tests.rs b/src/project_entry_tests.rs index 1b930f381..10a5151bb 100644 --- a/src/project_entry_tests.rs +++ b/src/project_entry_tests.rs @@ -420,8 +420,16 @@ fn setup_rejects_retired_agent_toml_only_with_migration_guidance() { let error = setup(&options).unwrap_err(); assert_eq!(error.code, "project_registration_invalid"); - assert!(error.message.contains("retired Runner config"), "{}", error.message); - assert!(error.message.contains("rename it to runner.toml"), "{}", error.message); + assert!( + error.message.contains("retired Runner config"), + "{}", + error.message + ); + assert!( + error.message.contains("rename it to runner.toml"), + "{}", + error.message + ); assert_eq!(fs::read(&legacy_config).unwrap(), original); assert!(!runner_config.exists()); } @@ -451,12 +459,19 @@ fn setup_rejects_retired_projects_dir_without_rewriting_config() { let runner_config = state.join("agent/runner.toml"); let canonical = fs::read_to_string(&runner_config).unwrap(); let retired = canonical.replace("project_registry_dir", "projects_dir"); - assert_ne!(retired, canonical, "fixture must contain project_registry_dir"); + assert_ne!( + retired, canonical, + "fixture must contain project_registry_dir" + ); fs::write(&runner_config, &retired).unwrap(); let error = setup(&options).unwrap_err(); assert_eq!(error.code, "project_registration_invalid"); - assert!(error.message.contains("'projects_dir'"), "{}", error.message); + assert!( + error.message.contains("'projects_dir'"), + "{}", + error.message + ); assert!( error.message.contains("'project_registry_dir'"), "{}",