1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::{Duration, SystemTime, UNIX_EPOCH};
4
5use base64::Engine;
6use base64::engine::general_purpose::URL_SAFE_NO_PAD;
7use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
8use getrandom::fill as getrandom_fill;
9use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
10use serde::{Deserialize, Serialize};
11
12use crate::config::{ConfigPathOptions, get_config_dir, resolve_config};
13use crate::constants::{AGENTS_DIR, AIT_FILE_NAME, SECRET_KEY_FILE_NAME};
14use crate::error::{CoreError, Result};
15use crate::http::blocking_client;
16use crate::identity::decode_secret_key;
17use crate::signing::{SignHttpRequestInput, sign_http_request};
18
19const FILE_MODE: u32 = 0o600;
20const IDENTITY_FILE: &str = "identity.json";
21const PUBLIC_KEY_FILE: &str = "public.key";
22const REGISTRY_AUTH_FILE: &str = "registry-auth.json";
23
24const AGENT_REGISTRATION_CHALLENGE_PATH: &str = "/v1/agents/challenge";
25const AGENT_REGISTRATION_PATH: &str = "/v1/agents";
26const AGENT_AUTH_REFRESH_PATH: &str = "/v1/agents/auth/refresh";
27const AGENT_REGISTRATION_PROOF_VERSION: &str = "clawdentity.register.v1";
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct AgentIdentityRecord {
32 pub did: String,
33 pub name: String,
34 pub framework: String,
35 pub expires_at: String,
36 pub registry_url: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase")]
41pub struct AgentAuthRecord {
42 pub token_type: String,
43 pub access_token: String,
44 pub access_expires_at: String,
45 pub refresh_token: String,
46 pub refresh_expires_at: String,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
50#[serde(rename_all = "camelCase")]
51pub struct AgentCreateResult {
52 pub name: String,
53 pub did: String,
54 pub expires_at: String,
55 pub framework: String,
56 pub registry_url: String,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
60#[serde(rename_all = "camelCase")]
61pub struct AgentInspectResult {
62 pub did: String,
63 pub owner_did: String,
64 pub expires_at: String,
65 pub key_id: String,
66 pub public_key: String,
67 pub framework: String,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "camelCase")]
72pub struct AgentAuthRefreshResult {
73 pub name: String,
74 pub status: String,
75 pub message: String,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
79#[serde(rename_all = "camelCase")]
80pub struct AgentAuthRevokeResult {
81 pub name: String,
82 pub status: String,
83 pub message: String,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct CreateAgentInput {
88 pub name: String,
89 pub framework: Option<String>,
90 pub ttl_days: Option<u32>,
91}
92
93#[derive(Debug, Deserialize)]
94#[serde(rename_all = "camelCase")]
95struct AgentRegistrationChallengeResponse {
96 challenge_id: String,
97 nonce: String,
98 owner_did: String,
99}
100
101#[derive(Debug, Deserialize)]
102#[serde(rename_all = "camelCase")]
103struct AgentRegistrationResponse {
104 agent: RegisteredAgentPayload,
105 ait: String,
106 agent_auth: AgentAuthRecord,
107}
108
109#[derive(Debug, Deserialize)]
110#[serde(rename_all = "camelCase")]
111struct RegisteredAgentPayload {
112 did: String,
113 name: String,
114 framework: String,
115 expires_at: String,
116}
117
118#[derive(Debug, Deserialize)]
119#[serde(rename_all = "camelCase")]
120struct ErrorEnvelope {
121 error: Option<RegistryError>,
122}
123
124#[derive(Debug, Deserialize)]
125#[serde(rename_all = "camelCase")]
126struct RegistryError {
127 message: Option<String>,
128}
129
130fn set_secure_permissions(path: &Path) -> Result<()> {
131 #[cfg(unix)]
132 {
133 use std::os::unix::fs::PermissionsExt;
134 let perms = fs::Permissions::from_mode(FILE_MODE);
135 fs::set_permissions(path, perms).map_err(|source| CoreError::Io {
136 path: path.to_path_buf(),
137 source,
138 })?;
139 }
140 Ok(())
141}
142
143fn write_secure_text(path: &Path, contents: &str) -> Result<()> {
144 if let Some(parent) = path.parent() {
145 fs::create_dir_all(parent).map_err(|source| CoreError::Io {
146 path: parent.to_path_buf(),
147 source,
148 })?;
149 }
150 fs::write(path, contents).map_err(|source| CoreError::Io {
151 path: path.to_path_buf(),
152 source,
153 })?;
154 set_secure_permissions(path)?;
155 Ok(())
156}
157
158fn write_secure_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
159 let body = serde_json::to_string_pretty(value)?;
160 write_secure_text(path, &format!("{body}\n"))
161}
162
163fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
164 let raw = fs::read_to_string(path).map_err(|source| CoreError::Io {
165 path: path.to_path_buf(),
166 source,
167 })?;
168 serde_json::from_str::<T>(&raw).map_err(|source| CoreError::JsonParse {
169 path: path.to_path_buf(),
170 source,
171 })
172}
173
174fn parse_agent_name(name: &str) -> Result<String> {
175 let candidate = name.trim();
176 if candidate.is_empty() {
177 return Err(CoreError::InvalidInput(
178 "agent name is required".to_string(),
179 ));
180 }
181 if candidate == "." || candidate == ".." {
182 return Err(CoreError::InvalidInput(
183 "agent name must not be . or ..".to_string(),
184 ));
185 }
186 if candidate.len() > 64 {
187 return Err(CoreError::InvalidInput(
188 "agent name must be <= 64 characters".to_string(),
189 ));
190 }
191 let valid = candidate
192 .chars()
193 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.');
194 if !valid {
195 return Err(CoreError::InvalidInput(
196 "agent name contains invalid characters".to_string(),
197 ));
198 }
199 Ok(candidate.to_string())
200}
201
202fn parse_optional_framework(value: Option<String>) -> Result<Option<String>> {
203 let Some(raw) = value else {
204 return Ok(None);
205 };
206 let normalized = raw.trim();
207 if normalized.is_empty() {
208 return Err(CoreError::InvalidInput(
209 "framework cannot be empty when provided".to_string(),
210 ));
211 }
212 Ok(Some(normalized.to_string()))
213}
214
215fn parse_optional_ttl_days(value: Option<u32>) -> Result<Option<u32>> {
216 match value {
217 Some(0) => Err(CoreError::InvalidInput(
218 "ttlDays must be a positive integer".to_string(),
219 )),
220 Some(days) => Ok(Some(days)),
221 None => Ok(None),
222 }
223}
224
225fn agents_dir(options: &ConfigPathOptions) -> Result<PathBuf> {
226 Ok(get_config_dir(options)?.join(AGENTS_DIR))
227}
228
229fn agent_dir(options: &ConfigPathOptions, name: &str) -> Result<PathBuf> {
230 Ok(agents_dir(options)?.join(name))
231}
232
233fn now_unix_seconds() -> Result<u64> {
234 Ok(SystemTime::now()
235 .duration_since(UNIX_EPOCH)
236 .map_err(|error| CoreError::InvalidInput(error.to_string()))?
237 .as_secs())
238}
239
240fn unix_seconds_to_iso(seconds: u64) -> Result<String> {
241 let dt = UNIX_EPOCH
242 .checked_add(Duration::from_secs(seconds))
243 .ok_or_else(|| CoreError::InvalidInput("invalid timestamp".to_string()))?;
244 let datetime: chrono::DateTime<chrono::Utc> = dt.into();
245 Ok(datetime.to_rfc3339())
246}
247
248fn decode_jwt_payload(token: &str) -> Result<serde_json::Value> {
249 let parts: Vec<&str> = token.split('.').collect();
250 if parts.len() < 2 {
251 return Err(CoreError::InvalidInput("invalid AIT token".to_string()));
252 }
253 let payload = URL_SAFE_NO_PAD
254 .decode(parts[1])
255 .map_err(|error| CoreError::Base64Decode(error.to_string()))?;
256 serde_json::from_slice(&payload).map_err(|error| CoreError::InvalidInput(error.to_string()))
257}
258
259fn decode_jwt_header(token: &str) -> Result<serde_json::Value> {
260 let parts: Vec<&str> = token.split('.').collect();
261 if parts.is_empty() {
262 return Err(CoreError::InvalidInput("invalid AIT token".to_string()));
263 }
264 let header = URL_SAFE_NO_PAD
265 .decode(parts[0])
266 .map_err(|error| CoreError::Base64Decode(error.to_string()))?;
267 serde_json::from_slice(&header).map_err(|error| CoreError::InvalidInput(error.to_string()))
268}
269
270fn join_url(base: &str, path: &str) -> Result<String> {
271 let base_url = url::Url::parse(base).map_err(|_| CoreError::InvalidUrl {
272 context: "registryUrl",
273 value: base.to_string(),
274 })?;
275 let joined = base_url.join(path).map_err(|_| CoreError::InvalidUrl {
276 context: "registryUrl",
277 value: base.to_string(),
278 })?;
279 Ok(joined.to_string())
280}
281
282fn parse_error_message(response_body: &str) -> String {
283 match serde_json::from_str::<ErrorEnvelope>(response_body) {
284 Ok(envelope) => envelope
285 .error
286 .and_then(|error| error.message)
287 .unwrap_or_else(|| response_body.to_string()),
288 Err(_) => response_body.to_string(),
289 }
290}
291
292fn canonicalize_agent_registration_proof(input: &CanonicalProofInput<'_>) -> String {
293 [
294 AGENT_REGISTRATION_PROOF_VERSION.to_string(),
295 format!("challengeId:{}", input.challenge_id),
296 format!("nonce:{}", input.nonce),
297 format!("ownerDid:{}", input.owner_did),
298 format!("publicKey:{}", input.public_key),
299 format!("name:{}", input.name),
300 format!("framework:{}", input.framework.unwrap_or("")),
301 format!(
302 "ttlDays:{}",
303 input
304 .ttl_days
305 .map(|value| value.to_string())
306 .unwrap_or_default()
307 ),
308 ]
309 .join("\n")
310}
311
312struct CanonicalProofInput<'a> {
313 challenge_id: &'a str,
314 nonce: &'a str,
315 owner_did: &'a str,
316 public_key: &'a str,
317 name: &'a str,
318 framework: Option<&'a str>,
319 ttl_days: Option<u32>,
320}
321
322struct AgentRegistrationRequest<'a> {
323 name: &'a str,
324 public_key: &'a str,
325 challenge_id: &'a str,
326 challenge_signature: &'a str,
327 framework: Option<&'a str>,
328 ttl_days: Option<u32>,
329}
330
331fn request_registration_challenge(
332 client: &reqwest::blocking::Client,
333 registry_url: &str,
334 api_key: &str,
335 public_key: &str,
336) -> Result<AgentRegistrationChallengeResponse> {
337 let url = join_url(registry_url, AGENT_REGISTRATION_CHALLENGE_PATH)?;
338 let response = client
339 .post(url)
340 .header(AUTHORIZATION, format!("Bearer {api_key}"))
341 .header(CONTENT_TYPE, "application/json")
342 .json(&serde_json::json!({
343 "publicKey": public_key,
344 }))
345 .send()
346 .map_err(|error| CoreError::Http(error.to_string()))?;
347
348 if !response.status().is_success() {
349 let status = response.status().as_u16();
350 let response_body = response.text().unwrap_or_default();
351 let message = parse_error_message(&response_body);
352 return Err(CoreError::HttpStatus { status, message });
353 }
354
355 response
356 .json::<AgentRegistrationChallengeResponse>()
357 .map_err(|error| CoreError::Http(error.to_string()))
358}
359
360fn request_agent_registration(
361 client: &reqwest::blocking::Client,
362 registry_url: &str,
363 api_key: &str,
364 input: AgentRegistrationRequest<'_>,
365) -> Result<AgentRegistrationResponse> {
366 let mut request_body = serde_json::json!({
367 "name": input.name,
368 "publicKey": input.public_key,
369 "challengeId": input.challenge_id,
370 "challengeSignature": input.challenge_signature,
371 });
372 if let Some(value) = input.framework {
373 request_body["framework"] = serde_json::Value::String(value.to_string());
374 }
375 if let Some(value) = input.ttl_days {
376 request_body["ttlDays"] = serde_json::Value::Number(value.into());
377 }
378
379 let url = join_url(registry_url, AGENT_REGISTRATION_PATH)?;
380 let response = client
381 .post(url)
382 .header(AUTHORIZATION, format!("Bearer {api_key}"))
383 .header(CONTENT_TYPE, "application/json")
384 .json(&request_body)
385 .send()
386 .map_err(|error| CoreError::Http(error.to_string()))?;
387
388 if !response.status().is_success() {
389 let status = response.status().as_u16();
390 let response_body = response.text().unwrap_or_default();
391 let message = parse_error_message(&response_body);
392 return Err(CoreError::HttpStatus { status, message });
393 }
394
395 response
396 .json::<AgentRegistrationResponse>()
397 .map_err(|error| CoreError::Http(error.to_string()))
398}
399
400fn random_nonce_base64url(size: usize) -> Result<String> {
401 let mut nonce = vec![0_u8; size];
402 getrandom_fill(&mut nonce).map_err(|error| CoreError::InvalidInput(error.to_string()))?;
403 Ok(URL_SAFE_NO_PAD.encode(nonce))
404}
405
406fn path_with_query(request_url: &str) -> Result<String> {
407 let parsed = url::Url::parse(request_url).map_err(|_| CoreError::InvalidUrl {
408 context: "registryUrl",
409 value: request_url.to_string(),
410 })?;
411 Ok(match parsed.query() {
412 Some(query) => format!("{}?{query}", parsed.path()),
413 None => parsed.path().to_string(),
414 })
415}
416
417fn parse_agent_auth_response(payload: serde_json::Value) -> Result<AgentAuthRecord> {
418 let source = payload.get("agentAuth").cloned().unwrap_or(payload);
419 let parsed = serde_json::from_value::<AgentAuthRecord>(source)
420 .map_err(|error| CoreError::InvalidInput(error.to_string()))?;
421 if parsed.token_type != "Bearer" {
422 return Err(CoreError::InvalidInput(
423 "invalid tokenType in agentAuth response".to_string(),
424 ));
425 }
426 Ok(parsed)
427}
428
429#[allow(clippy::too_many_lines)]
431pub fn create_agent(
432 options: &ConfigPathOptions,
433 input: CreateAgentInput,
434) -> Result<AgentCreateResult> {
435 let config = resolve_config(options)?;
436 let api_key = config.api_key.ok_or_else(|| {
437 CoreError::InvalidInput(
438 "API key is not configured. Run `clawdentity config set apiKey <token>` first."
439 .to_string(),
440 )
441 })?;
442
443 let name = parse_agent_name(&input.name)?;
444 let framework = parse_optional_framework(input.framework)?;
445 let ttl_days = parse_optional_ttl_days(input.ttl_days)?;
446
447 let state_options = options.with_registry_hint(config.registry_url.clone());
448 let agent_directory = agent_dir(&state_options, &name)?;
449 if agent_directory.exists() {
450 return Err(CoreError::IdentityAlreadyExists(agent_directory));
451 }
452 fs::create_dir_all(&agent_directory).map_err(|source| CoreError::Io {
453 path: agent_directory.clone(),
454 source,
455 })?;
456
457 let mut secret_bytes = [0_u8; 32];
458 getrandom_fill(&mut secret_bytes)
459 .map_err(|error| CoreError::InvalidInput(error.to_string()))?;
460 let signing_key = SigningKey::from_bytes(&secret_bytes);
461 let verifying_key: VerifyingKey = signing_key.verifying_key();
462
463 let public_key = URL_SAFE_NO_PAD.encode(verifying_key.as_bytes());
464 let secret_key = URL_SAFE_NO_PAD.encode(signing_key.to_bytes());
465
466 let client = blocking_client()?;
467 let challenge =
468 request_registration_challenge(&client, &config.registry_url, &api_key, &public_key)?;
469 let canonical_proof = canonicalize_agent_registration_proof(&CanonicalProofInput {
470 challenge_id: &challenge.challenge_id,
471 nonce: &challenge.nonce,
472 owner_did: &challenge.owner_did,
473 public_key: &public_key,
474 name: &name,
475 framework: framework.as_deref(),
476 ttl_days,
477 });
478 let challenge_signature =
479 URL_SAFE_NO_PAD.encode(signing_key.sign(canonical_proof.as_bytes()).to_bytes());
480
481 let registration = request_agent_registration(
482 &client,
483 &config.registry_url,
484 &api_key,
485 AgentRegistrationRequest {
486 name: &name,
487 public_key: &public_key,
488 challenge_id: &challenge.challenge_id,
489 challenge_signature: &challenge_signature,
490 framework: framework.as_deref(),
491 ttl_days,
492 },
493 )?;
494
495 let identity = AgentIdentityRecord {
496 did: registration.agent.did.clone(),
497 name: registration.agent.name.clone(),
498 framework: registration.agent.framework.clone(),
499 expires_at: registration.agent.expires_at.clone(),
500 registry_url: config.registry_url.clone(),
501 };
502
503 write_secure_json(&agent_directory.join(IDENTITY_FILE), &identity)?;
504 write_secure_text(
505 &agent_directory.join(AIT_FILE_NAME),
506 registration.ait.trim(),
507 )?;
508 write_secure_text(&agent_directory.join(SECRET_KEY_FILE_NAME), &secret_key)?;
509 write_secure_text(&agent_directory.join(PUBLIC_KEY_FILE), &public_key)?;
510 write_secure_json(
511 &agent_directory.join(REGISTRY_AUTH_FILE),
512 ®istration.agent_auth,
513 )?;
514
515 Ok(AgentCreateResult {
516 name: registration.agent.name,
517 did: registration.agent.did,
518 expires_at: registration.agent.expires_at,
519 framework: registration.agent.framework,
520 registry_url: config.registry_url,
521 })
522}
523
524#[allow(clippy::too_many_lines)]
526pub fn inspect_agent(options: &ConfigPathOptions, name: &str) -> Result<AgentInspectResult> {
527 let name = parse_agent_name(name)?;
528 let agent_directory = agent_dir(options, &name)?;
529 let ait_path = agent_directory.join(AIT_FILE_NAME);
530 let raw = fs::read_to_string(&ait_path).map_err(|source| CoreError::Io {
531 path: ait_path.clone(),
532 source,
533 })?;
534 let token = raw.trim();
535 let header = decode_jwt_header(token)?;
536 let payload = decode_jwt_payload(token)?;
537
538 let did = payload
539 .get("sub")
540 .and_then(|value| value.as_str())
541 .unwrap_or_default()
542 .to_string();
543 let owner_did = payload
544 .get("ownerDid")
545 .and_then(|value| value.as_str())
546 .unwrap_or_default()
547 .to_string();
548 let framework = payload
549 .get("framework")
550 .and_then(|value| value.as_str())
551 .unwrap_or("openclaw")
552 .to_string();
553 let public_key = payload
554 .get("cnf")
555 .and_then(|value| value.get("jwk"))
556 .and_then(|value| value.get("x"))
557 .and_then(|value| value.as_str())
558 .unwrap_or_default()
559 .to_string();
560 let key_id = header
561 .get("kid")
562 .and_then(|value| value.as_str())
563 .unwrap_or_default()
564 .to_string();
565 let exp = payload
566 .get("exp")
567 .and_then(|value| value.as_u64())
568 .unwrap_or_default();
569 let expires_at = unix_seconds_to_iso(exp)?;
570
571 if did.is_empty() || owner_did.is_empty() || key_id.is_empty() || public_key.is_empty() {
572 return Err(CoreError::InvalidInput(
573 "agent AIT payload is invalid".to_string(),
574 ));
575 }
576
577 Ok(AgentInspectResult {
578 did,
579 owner_did,
580 expires_at,
581 key_id,
582 public_key,
583 framework,
584 })
585}
586
587#[allow(clippy::too_many_lines)]
589pub fn refresh_agent_auth(
590 options: &ConfigPathOptions,
591 name: &str,
592) -> Result<AgentAuthRefreshResult> {
593 let name = parse_agent_name(name)?;
594 let agent_directory = agent_dir(options, &name)?;
595
596 let auth_path = agent_directory.join(REGISTRY_AUTH_FILE);
597 let current_auth: AgentAuthRecord = read_json(&auth_path)?;
598
599 let identity_path = agent_directory.join(IDENTITY_FILE);
600 let identity: AgentIdentityRecord = read_json(&identity_path)?;
601 if identity.registry_url.trim().is_empty() {
602 return Err(CoreError::InvalidInput(
603 "agent identity is missing registryUrl".to_string(),
604 ));
605 }
606
607 let ait_path = agent_directory.join(AIT_FILE_NAME);
608 let ait_raw = fs::read_to_string(&ait_path).map_err(|source| CoreError::Io {
609 path: ait_path.clone(),
610 source,
611 })?;
612 let ait = ait_raw.trim();
613 if ait.is_empty() {
614 return Err(CoreError::InvalidInput(
615 "agent AIT token is empty".to_string(),
616 ));
617 }
618
619 let secret_path = agent_directory.join(SECRET_KEY_FILE_NAME);
620 let secret_raw = fs::read_to_string(&secret_path).map_err(|source| CoreError::Io {
621 path: secret_path.clone(),
622 source,
623 })?;
624 let signing_key = decode_secret_key(secret_raw.trim())?;
625
626 let request_body = serde_json::json!({
627 "refreshToken": current_auth.refresh_token,
628 });
629 let request_body_bytes = serde_json::to_vec(&request_body)?;
630 let refresh_url = join_url(&identity.registry_url, AGENT_AUTH_REFRESH_PATH)?;
631 let path_with_query = path_with_query(&refresh_url)?;
632 let timestamp = now_unix_seconds()?.to_string();
633 let nonce = random_nonce_base64url(16)?;
634 let signed = sign_http_request(&SignHttpRequestInput {
635 method: "POST",
636 path_with_query: &path_with_query,
637 timestamp: ×tamp,
638 nonce: &nonce,
639 body: &request_body_bytes,
640 secret_key: &signing_key,
641 })?;
642
643 let mut request = blocking_client()?
644 .post(refresh_url)
645 .header(AUTHORIZATION, format!("Claw {ait}"))
646 .header(CONTENT_TYPE, "application/json");
647 for (header_name, value) in signed.headers {
648 request = request.header(&header_name, value);
649 }
650
651 let response = request
652 .body(request_body_bytes)
653 .send()
654 .map_err(|error| CoreError::Http(error.to_string()))?;
655
656 if !response.status().is_success() {
657 let status = response.status().as_u16();
658 let response_body = response.text().unwrap_or_default();
659 let message = parse_error_message(&response_body);
660 return Err(CoreError::HttpStatus { status, message });
661 }
662
663 let payload = response
664 .json::<serde_json::Value>()
665 .map_err(|error| CoreError::Http(error.to_string()))?;
666 let refreshed = parse_agent_auth_response(payload)?;
667 write_secure_json(&auth_path, &refreshed)?;
668
669 Ok(AgentAuthRefreshResult {
670 name,
671 status: "refreshed".to_string(),
672 message: "agent auth bundle updated".to_string(),
673 })
674}
675
676pub fn revoke_agent_auth(options: &ConfigPathOptions, name: &str) -> Result<AgentAuthRevokeResult> {
678 let name = parse_agent_name(name)?;
679 let agent_directory = agent_dir(options, &name)?;
680 let auth_path = agent_directory.join(REGISTRY_AUTH_FILE);
681 let _: AgentAuthRecord = read_json(&auth_path)?;
682
683 Ok(AgentAuthRevokeResult {
684 name,
685 status: "not_supported".to_string(),
686 message: "not yet supported by registry".to_string(),
687 })
688}
689
690#[cfg(test)]
691#[path = "agent_tests.rs"]
692mod tests;