| 1 | use super::*; |
| 2 | |
| 3 | fn entry<'a>(commands: &'a [CommandCatalogEntry], name: &str) -> &'a CommandCatalogEntry { |
| 4 | commands |
| 5 | .iter() |
| 6 | .find(|command| command.name == name) |
| 7 | .unwrap_or_else(|| panic!("catalog must contain {name}")) |
| 8 | } |
| 9 | |
| 10 | #[test] |
| 11 | fn command_catalog_serves_builtins_with_host_binding() { |
| 12 | let users = crate::commands::user_registry::UserCommandRegistry::new(); |
| 13 | let commands = command_catalog(&users); |
| 14 | |
| 15 | let model = entry(&commands, "model"); |
| 16 | assert_eq!(model.kind, "builtin"); |
| 17 | assert_eq!(model.binding, "host"); |
| 18 | assert_eq!(model.discovery, Some("primary")); |
| 19 | assert!(!model.hidden); |
| 20 | assert_eq!(model.shadowed_by, None); |
| 21 | assert!(model.summary.is_some()); |
| 22 | assert!(model.usage.is_some()); |
| 23 | assert!(model.takes_arguments); |
| 24 | |
| 25 | // Composer shape comes from the same predicates the TUI composer uses: |
| 26 | // `/profile <name>` cannot run bare. |
| 27 | let profile = entry(&commands, "profile"); |
| 28 | assert!(profile.requires_argument); |
| 29 | assert!(profile.requires_required_argument); |
| 30 | assert!(profile.composer_wants_trailing_space); |
| 31 | assert!(!profile.palette_runs_directly); |
| 32 | |
| 33 | // Unlisted builtins run but are not advertised — hidden, not absent. |
| 34 | assert!(entry(&commands, "lane").hidden); |
| 35 | |
| 36 | // A usage line's literal verbs surface as subcommands. |
| 37 | let goal = entry(&commands, "goal"); |
| 38 | assert!( |
| 39 | goal.subcommands.iter().any(|verb| verb == "blocked"), |
| 40 | "goal usage should declare its verbs: {:?}", |
| 41 | goal.subcommands |
| 42 | ); |
| 43 | } |
| 44 | |
| 45 | #[test] |
| 46 | fn command_catalog_marks_user_shadowing_of_builtin_names_and_aliases() { |
| 47 | let users = crate::commands::user_registry::UserCommandRegistry::from_loaded(vec![ |
| 48 | ("model".to_string(), "Pick the fast route.".to_string()), |
| 49 | ("agents".to_string(), "Alias-shaped command.".to_string()), |
| 50 | ]); |
| 51 | let commands = command_catalog(&users); |
| 52 | |
| 53 | let model = entry(&commands, "model"); |
| 54 | assert_eq!(model.shadowed_by.as_deref(), Some("model")); |
| 55 | |
| 56 | // The shadowing user command is served as a prompt-bound row. |
| 57 | let user_model = commands |
| 58 | .iter() |
| 59 | .find(|command| command.name == "model" && command.kind == "user") |
| 60 | .expect("user command row"); |
| 61 | assert_eq!(user_model.binding, "prompt"); |
| 62 | |
| 63 | // A user command colliding with a builtin's alias shadows that spelling: |
| 64 | // `agents` is a `subagents` alias, so the builtin reports it while keeping |
| 65 | // its canonical name. |
| 66 | let subagents = entry(&commands, "subagents"); |
| 67 | assert_eq!(subagents.kind, "builtin"); |
| 68 | assert_eq!(subagents.shadowed_by, None); |
| 69 | assert!( |
| 70 | subagents.shadowed_aliases.iter().any(|a| a == "agents"), |
| 71 | "shadowed_aliases must report the taken spelling: {:?}", |
| 72 | subagents.shadowed_aliases |
| 73 | ); |
| 74 | } |
| 75 | |
| 76 | #[tokio::test] |
| 77 | async fn get_v1_commands_serves_the_catalog_over_http() -> Result<()> { |
| 78 | let _env = lock_test_env(); |
| 79 | let temp = tempfile::tempdir()?; |
| 80 | let root = temp.path().join("commands-route"); |
| 81 | let sessions_dir = root.join("sessions"); |
| 82 | let workspace = root.join("workspace"); |
| 83 | let commands_dir = workspace.join(".codewhale").join("commands"); |
| 84 | fs::create_dir_all(&commands_dir)?; |
| 85 | fs::write(commands_dir.join("model.md"), "Pick the fast route.\n")?; |
| 86 | // Workspace commands load only in a trusted workspace. A sealed config |
| 87 | // path lets the server's threads read the same trust record. |
| 88 | let _config = crate::test_support::EnvVarGuard::set( |
| 89 | "CODEWHALE_CONFIG_PATH", |
| 90 | temp.path().join("config.toml"), |
| 91 | ); |
| 92 | crate::test_support::trust_workspace(&workspace); |
| 93 | |
| 94 | let Some((addr, _runtime_threads, handle)) = |
| 95 | spawn_test_server_with_root_token_mobile_workspace( |
| 96 | root, |
| 97 | sessions_dir, |
| 98 | None, |
| 99 | false, |
| 100 | workspace, |
| 101 | ) |
| 102 | .await? |
| 103 | else { |
| 104 | return Ok(()); |
| 105 | }; |
| 106 | let client = crate::tls::reqwest_client(); |
| 107 | |
| 108 | let body: serde_json::Value = client |
| 109 | .get(format!("http://{addr}/v1/commands")) |
| 110 | .send() |
| 111 | .await? |
| 112 | .error_for_status()? |
| 113 | .json() |
| 114 | .await?; |
| 115 | let commands = body["commands"].as_array().expect("commands array"); |
| 116 | assert!( |
| 117 | commands |
| 118 | .iter() |
| 119 | .any(|command| command["kind"] == "builtin" && command["binding"] == "host"), |
| 120 | "the route must serve builtins: {commands:?}" |
| 121 | ); |
| 122 | |
| 123 | // The workspace user command named `model` shadows the builtin: the |
| 124 | // builtin reports the shadow and the winning definition is served. |
| 125 | let builtin_model = commands |
| 126 | .iter() |
| 127 | .find(|command| command["name"] == "model" && command["kind"] == "builtin") |
| 128 | .expect("builtin model row"); |
| 129 | assert_eq!(builtin_model["shadowed_by"], "model"); |
| 130 | let user_model = commands |
| 131 | .iter() |
| 132 | .find(|command| command["name"] == "model" && command["kind"] == "user") |
| 133 | .expect("user model row"); |
| 134 | assert_eq!(user_model["binding"], "prompt"); |
| 135 | // A template without `$ARGUMENTS` runs bare from the palette. |
| 136 | assert_eq!(user_model["requires_required_argument"], false); |
| 137 | assert_eq!(user_model["palette_runs_directly"], true); |
| 138 | assert_eq!(user_model["show_in_empty_discovery"], true); |
| 139 | |
| 140 | let profile = commands |
| 141 | .iter() |
| 142 | .find(|command| command["name"] == "profile" && command["kind"] == "builtin") |
| 143 | .expect("builtin profile row"); |
| 144 | assert_eq!(profile["requires_required_argument"], true); |
| 145 | assert_eq!(profile["palette_runs_directly"], false); |
| 146 | |
| 147 | handle.abort(); |
| 148 | Ok(()) |
| 149 | } |
| 150 | |
| 151 | /// B4: `GET /v1/hooks` serves the hook set Runtime threads run, from the |
| 152 | /// engine's own loader, with credential-shaped values masked. |
| 153 | #[tokio::test] |
| 154 | async fn get_v1_hooks_serves_the_runtime_hook_set() -> Result<()> { |
| 155 | let _env = lock_test_env(); |
| 156 | let temp = tempfile::tempdir()?; |
| 157 | let root = temp.path().join("hooks-route"); |
| 158 | let sessions_dir = root.join("sessions"); |
| 159 | let workspace = root.join("workspace"); |
| 160 | fs::create_dir_all(&workspace)?; |
| 161 | let config = Config { |
| 162 | hooks: Some(crate::hooks::HooksConfig { |
| 163 | hooks: vec![ |
| 164 | crate::hooks::Hook::new( |
| 165 | crate::hooks::HookEvent::ToolCallAfter, |
| 166 | "curl -H 'Authorization: Bearer sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789' https://example.invalid", |
| 167 | ) |
| 168 | .with_name("notify"), |
| 169 | ], |
| 170 | enabled: true, |
| 171 | ..crate::hooks::HooksConfig::default() |
| 172 | }), |
| 173 | ..Config::default() |
| 174 | }; |
| 175 | |
| 176 | let Some((addr, _runtime_threads, handle)) = |
| 177 | spawn_test_server_with_root_token_mobile_workspace_and_overrides( |
| 178 | root, |
| 179 | sessions_dir, |
| 180 | None, |
| 181 | false, |
| 182 | workspace, |
| 183 | TestServerOverrides { |
| 184 | config: Some(config), |
| 185 | ..TestServerOverrides::default() |
| 186 | }, |
| 187 | ) |
| 188 | .await? |
| 189 | else { |
| 190 | return Ok(()); |
| 191 | }; |
| 192 | let client = crate::tls::reqwest_client(); |
| 193 | let body: serde_json::Value = client |
| 194 | .get(format!("http://{addr}/v1/hooks")) |
| 195 | .send() |
| 196 | .await? |
| 197 | .error_for_status()? |
| 198 | .json() |
| 199 | .await?; |
| 200 | assert_eq!(body["enabled"], true); |
| 201 | let hooks = body["hooks"].as_array().expect("hooks array"); |
| 202 | assert_eq!(hooks.len(), 1, "{body}"); |
| 203 | assert_eq!(hooks[0]["name"], "notify"); |
| 204 | assert_eq!(hooks[0]["event"], "tool_call_after"); |
| 205 | assert_eq!(hooks[0]["source"], "global"); |
| 206 | let command = hooks[0]["command"].as_str().expect("command"); |
| 207 | assert!(command.starts_with("curl"), "{command}"); |
| 208 | assert!(!command.contains("sk-ant-api03-AbCd"), "{command}"); |
| 209 | |
| 210 | let missing = client |
| 211 | .get(format!("http://{addr}/v1/hooks?thread_id=thr_missing")) |
| 212 | .send() |
| 213 | .await?; |
| 214 | assert_eq!(missing.status(), reqwest::StatusCode::NOT_FOUND); |
| 215 | handle.abort(); |
| 216 | Ok(()) |
| 217 | } |
| 218 | |
| 219 | #[test] |
| 220 | fn hook_listing_masks_url_paths_and_userinfo() { |
| 221 | assert_eq!( |
| 222 | redact_hook_command_for_listing( |
| 223 | "curl -X POST https://hooks.slack.com/services/T000/B000/XXXXsecret -d @-" |
| 224 | ), |
| 225 | "curl -X POST https://hooks.slack.com/[redacted] -d @-" |
| 226 | ); |
| 227 | assert_eq!( |
| 228 | redact_hook_command_for_listing("notify 'https://user:pw@example.test/hook?k=v'"), |
| 229 | "notify 'https://example.test/[redacted]'" |
| 230 | ); |
| 231 | assert_eq!( |
| 232 | redact_hook_command_for_listing("ping https://example.test"), |
| 233 | "ping https://example.test" |
| 234 | ); |
| 235 | assert_eq!( |
| 236 | redact_hook_command_for_listing("./scripts/lint.sh --fix"), |
| 237 | "./scripts/lint.sh --fix" |
| 238 | ); |
| 239 | } |
| 240 |