Skip to main content

mars_agents/target/
codex.rs

1/// `.codex` target adapter.
2///
3/// Handles MCP server registration and hook binding for the Codex harness.
4///
5/// Codex-native lowering:
6/// - MCP: writes to `config.toml` (`[mcp.servers.*]`), env vars as plain names
7/// - Hooks: writes to `codex_hooks.json` with structural hook entries
8use std::path::{Path, PathBuf};
9
10use crate::compiler::mcp::{HeaderValue, McpTransport};
11use crate::error::MarsError;
12use crate::lock::ItemKind;
13use crate::types::DestPath;
14use toml_edit::{Array, DocumentMut, Item, Table, Value, value};
15
16use super::{ConfigEntry, HookEntry, McpServerEntry, TargetAdapter, hook_command};
17
18#[derive(Debug)]
19pub struct CodexAdapter;
20
21const CODEX_CONFIG_TOML: &str = "config.toml";
22const LEGACY_CODEX_MCP_JSON: &str = "codex_mcp.json";
23
24impl TargetAdapter for CodexAdapter {
25    fn name(&self) -> &str {
26        ".codex"
27    }
28
29    fn skill_variant_key(&self) -> Option<&str> {
30        Some("codex")
31    }
32
33    fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath> {
34        match kind {
35            ItemKind::Skill => Some(DestPath::from(format!("skills/{name}").as_str())),
36            _ => None,
37        }
38    }
39
40    fn write_config_entries(
41        &self,
42        entries: &[ConfigEntry],
43        target_dir: &Path,
44    ) -> Result<Vec<PathBuf>, MarsError> {
45        let mut written = Vec::new();
46
47        let mcp_servers: Vec<&McpServerEntry> = entries
48            .iter()
49            .filter_map(|e| {
50                if let ConfigEntry::McpServer(s) = e {
51                    Some(s)
52                } else {
53                    None
54                }
55            })
56            .collect();
57
58        let hooks: Vec<&HookEntry> = entries
59            .iter()
60            .filter_map(|e| {
61                if let ConfigEntry::Hook(h) = e {
62                    Some(h)
63                } else {
64                    None
65                }
66            })
67            .collect();
68
69        if !mcp_servers.is_empty() {
70            let path = write_codex_mcp_toml(target_dir, &mcp_servers)?;
71            written.push(path);
72        }
73
74        if !hooks.is_empty() {
75            let path = write_codex_hooks_json(target_dir, &hooks)?;
76            written.push(path);
77        }
78
79        Ok(written)
80    }
81
82    fn emit_pre_write_diagnostics(
83        &self,
84        entries: &[ConfigEntry],
85        target_dir: &Path,
86        diag: &mut crate::diagnostic::DiagnosticCollector,
87    ) {
88        let has_mcp_entries = entries
89            .iter()
90            .any(|entry| matches!(entry, ConfigEntry::McpServer(_)));
91        if !has_mcp_entries {
92            return;
93        }
94
95        let legacy_path = target_dir.join(LEGACY_CODEX_MCP_JSON);
96        if legacy_path.is_file() {
97            diag.info(
98                "legacy-config-cleanup",
99                format!(
100                    "target `.codex`: removing legacy MCP config `{}` during sync",
101                    legacy_path.display()
102                ),
103            );
104        }
105
106        let config_path = target_dir.join(CODEX_CONFIG_TOML);
107        if config_path.is_file()
108            && let Err(err) = parse_existing_toml_document(&config_path)
109        {
110            diag.warn(
111                "codex-config-parse-error",
112                format!(
113                    "target `.codex`: cannot parse `{}`; skipping Codex MCP writes/removals until fixed: {err}",
114                    config_path.display()
115                ),
116            );
117        }
118    }
119
120    fn remove_config_entries(
121        &self,
122        entry_keys: &[String],
123        target_dir: &Path,
124    ) -> Result<(), MarsError> {
125        remove_legacy_codex_mcp_json(target_dir)?;
126        remove_codex_mcp_entries(entry_keys, target_dir)?;
127        remove_codex_hook_entries(entry_keys, target_dir)?;
128        Ok(())
129    }
130}
131
132// ---------------------------------------------------------------------------
133// Codex MCP — `config.toml` format
134// ---------------------------------------------------------------------------
135//
136// Codex uses plain environment variable names (no interpolation syntax).
137// Format:
138// [mcp.servers.my-server]
139// command = "npx"
140// args = ["-y", "my-server@latest"]
141// env = ["MY_API_KEY"]
142
143fn write_codex_mcp_toml(
144    target_dir: &Path,
145    servers: &[&McpServerEntry],
146) -> Result<PathBuf, MarsError> {
147    let path = target_dir.join(CODEX_CONFIG_TOML);
148    remove_legacy_codex_mcp_json(target_dir)?;
149
150    let mut doc = load_or_new_toml_document(&path)?;
151    let mcp_servers = ensure_mcp_servers_table(&mut doc, &path)?;
152
153    for server in servers {
154        let mut server_table = Table::new();
155        match server.transport {
156            McpTransport::Stdio => {
157                if let Some(command) = server.command.as_ref() {
158                    server_table["command"] = value(command.as_str());
159                }
160                server_table["args"] = toml_string_array(server.args.clone());
161            }
162            McpTransport::Http => {
163                if let Some(url) = server.url.as_ref() {
164                    server_table["url"] = value(url.as_str());
165                }
166
167                let mut bearer_token_env_var: Option<String> = None;
168                let mut http_headers = Table::new();
169                for (header, value_ref) in &server.headers {
170                    match value_ref {
171                        HeaderValue::Plain(plain_value) => {
172                            http_headers[header.as_str()] = value(plain_value.as_str());
173                        }
174                        HeaderValue::EnvRef(env_ref) => {
175                            if header.eq_ignore_ascii_case("Authorization") {
176                                bearer_token_env_var = Some(env_ref.var_name().to_string());
177                            } else {
178                                http_headers[header.as_str()] = value(env_ref.var_name());
179                            }
180                        }
181                    }
182                }
183
184                if let Some(token_var) = bearer_token_env_var {
185                    server_table["bearer_token_env_var"] = value(token_var);
186                }
187                if !http_headers.is_empty() {
188                    server_table["http_headers"] = Item::Table(http_headers);
189                }
190            }
191        }
192
193        // Codex env: list of variable names (not a map with values).
194        if !server.env.is_empty() {
195            let env_vars: Vec<String> = server.env.values().cloned().collect();
196            server_table["env"] = toml_string_array(env_vars);
197        }
198
199        mcp_servers.insert(server.name.as_str(), Item::Table(server_table));
200    }
201
202    crate::fs::atomic_write(&path, doc.to_string().as_bytes())?;
203    Ok(path)
204}
205
206fn remove_codex_mcp_entries(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
207    let path = target_dir.join(CODEX_CONFIG_TOML);
208    if !path.is_file() {
209        return Ok(());
210    }
211
212    let mut doc = load_or_new_toml_document(&path)?;
213    let mcp_servers = ensure_mcp_servers_table(&mut doc, &path)?;
214
215    for key in entry_keys {
216        if let Some(name) = key.strip_prefix("mcp:") {
217            mcp_servers.remove(name);
218        }
219    }
220
221    crate::fs::atomic_write(&path, doc.to_string().as_bytes())?;
222    Ok(())
223}
224
225fn load_or_new_toml_document(path: &Path) -> Result<DocumentMut, MarsError> {
226    if !path.is_file() {
227        return Ok(DocumentMut::new());
228    }
229
230    parse_existing_toml_document(path)
231}
232
233fn parse_existing_toml_document(path: &Path) -> Result<DocumentMut, MarsError> {
234    let raw = std::fs::read_to_string(path).map_err(MarsError::from)?;
235    raw.parse::<DocumentMut>().map_err(|e| {
236        MarsError::Config(crate::error::ConfigError::Invalid {
237            message: format!(
238                "{}: failed to parse TOML; refusing to overwrite existing config: {e}",
239                path.display()
240            ),
241        })
242    })
243}
244
245fn toml_string_array(values: impl IntoIterator<Item = String>) -> Item {
246    let mut array = Array::new();
247    for value in values {
248        array.push(value);
249    }
250    Item::Value(Value::Array(array))
251}
252
253fn ensure_mcp_servers_table<'a>(
254    doc: &'a mut DocumentMut,
255    path: &Path,
256) -> Result<&'a mut Table, MarsError> {
257    let root = doc.as_table_mut();
258
259    let mcp_item = root
260        .entry("mcp")
261        .or_insert_with(|| Item::Table(Table::new()));
262    let mcp_table = mcp_item.as_table_mut().ok_or_else(|| {
263        MarsError::Config(crate::error::ConfigError::Invalid {
264            message: format!("{}: mcp is not a table", path.display()),
265        })
266    })?;
267
268    let servers_item = mcp_table
269        .entry("servers")
270        .or_insert_with(|| Item::Table(Table::new()));
271    servers_item.as_table_mut().ok_or_else(|| {
272        MarsError::Config(crate::error::ConfigError::Invalid {
273            message: format!("{}: mcp.servers is not a table", path.display()),
274        })
275    })
276}
277
278fn remove_legacy_codex_mcp_json(target_dir: &Path) -> Result<(), MarsError> {
279    let legacy_path = target_dir.join(LEGACY_CODEX_MCP_JSON);
280    if !legacy_path.is_file() {
281        return Ok(());
282    }
283
284    std::fs::remove_file(&legacy_path).map_err(MarsError::from)
285}
286
287// ---------------------------------------------------------------------------
288// Codex hooks — `codex_hooks.json` format
289// ---------------------------------------------------------------------------
290//
291// Structural hook entries — Codex uses event → command list mapping.
292// {
293//   "hooks": {
294//     "pre-exec": ["bash /path/to/script.sh"],
295//     "post-exec": [...]
296//   }
297// }
298
299fn write_codex_hooks_json(target_dir: &Path, hooks: &[&HookEntry]) -> Result<PathBuf, MarsError> {
300    let path = target_dir.join("codex_hooks.json");
301
302    let mut root: serde_json::Value = if path.is_file() {
303        let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
304        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
305    } else {
306        serde_json::json!({})
307    };
308
309    let hooks_section = root
310        .as_object_mut()
311        .ok_or_else(|| {
312            MarsError::Config(crate::error::ConfigError::Invalid {
313                message: format!("{} is not a JSON object", path.display()),
314            })
315        })?
316        .entry("hooks")
317        .or_insert_with(|| serde_json::json!({}));
318
319    let hooks_map = hooks_section.as_object_mut().ok_or_else(|| {
320        MarsError::Config(crate::error::ConfigError::Invalid {
321            message: format!("{}: hooks is not an object", path.display()),
322        })
323    })?;
324
325    for hook in hooks {
326        let command = hook_command(&hook.script_path);
327        let native_event = hook.native_event.clone();
328        let event_hooks = hooks_map
329            .entry(native_event.clone())
330            .or_insert_with(|| serde_json::json!([]))
331            .as_array_mut()
332            .ok_or_else(|| {
333                MarsError::Config(crate::error::ConfigError::Invalid {
334                    message: format!("{}: hooks.{native_event} is not an array", path.display()),
335                })
336            })?;
337        remove_managed_hook_commands(event_hooks, &hook.name);
338        event_hooks.push(serde_json::Value::String(command));
339    }
340
341    let content = serde_json::to_string_pretty(&root).map_err(|e| {
342        MarsError::Config(crate::error::ConfigError::Invalid {
343            message: format!("failed to serialize {}: {e}", path.display()),
344        })
345    })?;
346    crate::fs::atomic_write(&path, content.as_bytes())?;
347
348    Ok(path)
349}
350
351fn remove_managed_hook_commands(commands: &mut Vec<serde_json::Value>, hook_name: &str) {
352    commands.retain(|cmd| {
353        cmd.as_str()
354            .map(|cmd| !is_managed_hook_command_for(cmd, hook_name))
355            .unwrap_or(true)
356    });
357}
358
359fn is_managed_hook_command_for(command: &str, hook_name: &str) -> bool {
360    let normalized = command.replace('\\', "/").replace("//", "/");
361    normalized.contains(&format!("/hooks/{hook_name}/"))
362}
363
364fn remove_codex_hook_entries(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
365    let path = target_dir.join("codex_hooks.json");
366    if !path.is_file() {
367        return Ok(());
368    }
369
370    let hook_keys: Vec<(String, &str)> = entry_keys
371        .iter()
372        .filter_map(|k| {
373            let rest = k.strip_prefix("hook:")?;
374            let (event, name) = rest.split_once(':')?;
375            Some((codex_hook_event(event)?.to_string(), name))
376        })
377        .collect();
378
379    if hook_keys.is_empty() {
380        return Ok(());
381    }
382
383    let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
384    let mut root: serde_json::Value =
385        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
386
387    if let Some(hooks_map) = root
388        .as_object_mut()
389        .and_then(|o| o.get_mut("hooks"))
390        .and_then(|v| v.as_object_mut())
391    {
392        for (event, name) in &hook_keys {
393            if let Some(arr) = hooks_map.get_mut(event).and_then(|v| v.as_array_mut()) {
394                arr.retain(|cmd| {
395                    let cmd_str = cmd.as_str().unwrap_or("");
396                    // Exact path-segment match to avoid partial name collisions.
397                    !is_managed_hook_command_for(cmd_str, name)
398                });
399            }
400        }
401    }
402
403    let content = serde_json::to_string_pretty(&root).map_err(|e| {
404        MarsError::Config(crate::error::ConfigError::Invalid {
405            message: format!("failed to serialize {}: {e}", path.display()),
406        })
407    })?;
408    crate::fs::atomic_write(&path, content.as_bytes())?;
409    Ok(())
410}
411
412fn codex_hook_event(event: &str) -> Option<&'static str> {
413    match event {
414        "session.start" => Some("start"),
415        "session.end" => Some("stop"),
416        "tool.pre" => Some("pre-exec"),
417        "tool.post" => Some("post-exec"),
418        _ => None,
419    }
420}
421
422// ---------------------------------------------------------------------------
423// Tests
424// ---------------------------------------------------------------------------
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use crate::diagnostic::DiagnosticCollector;
430    use indexmap::IndexMap;
431    use tempfile::TempDir;
432    use toml::Value as TomlValue;
433
434    fn make_stdio_mcp_entry(name: &str) -> ConfigEntry {
435        let mut env = IndexMap::new();
436        env.insert("API_KEY".to_string(), "MY_SECRET".to_string());
437        ConfigEntry::McpServer(McpServerEntry {
438            name: name.to_string(),
439            transport: McpTransport::Stdio,
440            command: Some("npx".to_string()),
441            args: vec!["-y".to_string(), "some-mcp@latest".to_string()],
442            env,
443            url: None,
444            headers: IndexMap::new(),
445        })
446    }
447
448    fn make_http_mcp_entry(name: &str) -> ConfigEntry {
449        let mut headers = IndexMap::new();
450        headers.insert(
451            "Authorization".to_string(),
452            HeaderValue::EnvRef(crate::compiler::mcp::EnvRef::Env {
453                var: "API_TOKEN".to_string(),
454            }),
455        );
456        headers.insert(
457            "X-Custom".to_string(),
458            HeaderValue::Plain("static-value".to_string()),
459        );
460
461        ConfigEntry::McpServer(McpServerEntry {
462            name: name.to_string(),
463            transport: McpTransport::Http,
464            command: None,
465            args: vec![],
466            env: IndexMap::new(),
467            url: Some("https://api.example.com/mcp".to_string()),
468            headers,
469        })
470    }
471
472    fn make_hook_entry_with_path(name: &str, native: &str, script_path: &str) -> ConfigEntry {
473        ConfigEntry::Hook(HookEntry {
474            name: name.to_string(),
475            event: "tool.pre".to_string(),
476            native_event: native.to_string(),
477            script_path: script_path.to_string(),
478            order: 0,
479        })
480    }
481
482    #[test]
483    fn write_mcp_uses_config_toml_schema_and_preserves_non_mcp_content() {
484        let tmp = TempDir::new().unwrap();
485        std::fs::write(
486            tmp.path().join(CODEX_CONFIG_TOML),
487            r#"
488[ui]
489theme = "dark"
490"#,
491        )
492        .unwrap();
493        std::fs::write(tmp.path().join(LEGACY_CODEX_MCP_JSON), "{}").unwrap();
494
495        let adapter = CodexAdapter;
496        adapter
497            .write_config_entries(
498                &[
499                    make_stdio_mcp_entry("stdio-server"),
500                    make_http_mcp_entry("http-server"),
501                ],
502                tmp.path(),
503            )
504            .unwrap();
505
506        assert!(!tmp.path().join(LEGACY_CODEX_MCP_JSON).exists());
507
508        let raw = std::fs::read_to_string(tmp.path().join(CODEX_CONFIG_TOML)).unwrap();
509        let toml: TomlValue = toml::from_str(&raw).unwrap();
510        assert_eq!(toml["ui"]["theme"].as_str(), Some("dark"));
511
512        let stdio = &toml["mcp"]["servers"]["stdio-server"];
513        assert_eq!(stdio["command"].as_str(), Some("npx"));
514        assert_eq!(stdio["args"][0].as_str(), Some("-y"));
515        let env_arr = stdio["env"].as_array().unwrap();
516        assert!(env_arr.iter().any(|v| v.as_str() == Some("MY_SECRET")));
517
518        let http = &toml["mcp"]["servers"]["http-server"];
519        assert_eq!(http["url"].as_str(), Some("https://api.example.com/mcp"));
520        assert_eq!(http["bearer_token_env_var"].as_str(), Some("API_TOKEN"));
521        assert_eq!(
522            http["http_headers"]["X-Custom"].as_str(),
523            Some("static-value")
524        );
525        assert!(http.get("command").is_none());
526    }
527
528    #[test]
529    fn emit_pre_write_diagnostics_flags_legacy_cleanup_and_toml_parse_errors() {
530        let adapter = CodexAdapter;
531
532        let legacy_tmp = TempDir::new().unwrap();
533        std::fs::write(legacy_tmp.path().join(LEGACY_CODEX_MCP_JSON), "{}").unwrap();
534        let mut legacy_diag = DiagnosticCollector::new();
535        adapter.emit_pre_write_diagnostics(
536            &[make_stdio_mcp_entry("context7")],
537            legacy_tmp.path(),
538            &mut legacy_diag,
539        );
540        let legacy_messages = legacy_diag.drain();
541        assert_eq!(legacy_messages.len(), 1);
542        assert_eq!(legacy_messages[0].code, "legacy-config-cleanup");
543
544        let invalid_tmp = TempDir::new().unwrap();
545        std::fs::write(
546            invalid_tmp.path().join(CODEX_CONFIG_TOML),
547            "[ui
548",
549        )
550        .unwrap();
551        let mut invalid_diag = DiagnosticCollector::new();
552        adapter.emit_pre_write_diagnostics(
553            &[make_stdio_mcp_entry("context7")],
554            invalid_tmp.path(),
555            &mut invalid_diag,
556        );
557        let invalid_messages = invalid_diag.drain();
558        assert_eq!(invalid_messages.len(), 1);
559        assert_eq!(invalid_messages[0].code, "codex-config-parse-error");
560    }
561
562    #[test]
563    fn invalid_toml_is_not_clobbered_during_write_or_remove() {
564        let original = r#"[ui]
565theme = "dark"
566invalid =
567"#;
568
569        let write_tmp = TempDir::new().unwrap();
570        std::fs::write(write_tmp.path().join(CODEX_CONFIG_TOML), original).unwrap();
571        let adapter = CodexAdapter;
572        let write_err = adapter
573            .write_config_entries(&[make_stdio_mcp_entry("context7")], write_tmp.path())
574            .expect_err("invalid TOML should fail and not be overwritten");
575        assert!(write_err.to_string().contains("failed to parse TOML"));
576        assert_eq!(
577            std::fs::read_to_string(write_tmp.path().join(CODEX_CONFIG_TOML)).unwrap(),
578            original
579        );
580
581        let remove_tmp = TempDir::new().unwrap();
582        std::fs::write(remove_tmp.path().join(CODEX_CONFIG_TOML), original).unwrap();
583        let remove_err = adapter
584            .remove_config_entries(&["mcp:context7".to_string()], remove_tmp.path())
585            .expect_err("invalid TOML should fail and not be overwritten");
586        assert!(remove_err.to_string().contains("failed to parse TOML"));
587        assert_eq!(
588            std::fs::read_to_string(remove_tmp.path().join(CODEX_CONFIG_TOML)).unwrap(),
589            original
590        );
591    }
592
593    #[test]
594    fn write_hooks_replaces_existing_managed_hook_for_same_name_and_event() {
595        let tmp = TempDir::new().unwrap();
596        let adapter = CodexAdapter;
597        adapter
598            .write_config_entries(
599                &[make_hook_entry_with_path(
600                    "audit",
601                    "pre-exec",
602                    "/old/hooks/audit/run.sh",
603                )],
604                tmp.path(),
605            )
606            .unwrap();
607        adapter
608            .write_config_entries(
609                &[make_hook_entry_with_path(
610                    "audit",
611                    "pre-exec",
612                    "/new/hooks/audit/run.sh",
613                )],
614                tmp.path(),
615            )
616            .unwrap();
617
618        let raw = std::fs::read_to_string(tmp.path().join("codex_hooks.json")).unwrap();
619        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
620        let hooks = json["hooks"]["pre-exec"].as_array().unwrap();
621        assert_eq!(hooks.len(), 1);
622        assert!(hooks[0].as_str().unwrap().contains("/new/hooks/audit/"));
623    }
624
625    #[test]
626    fn remove_entries_clean_up_mcp_and_only_matching_hook_event() {
627        let tmp = TempDir::new().unwrap();
628        let adapter = CodexAdapter;
629
630        adapter
631            .write_config_entries(
632                &[
633                    make_stdio_mcp_entry("to-remove"),
634                    make_stdio_mcp_entry("to-keep"),
635                    make_hook_entry_with_path("audit", "pre-exec", "/pkg/hooks/audit/run.sh"),
636                    make_hook_entry_with_path("audit", "post-exec", "/pkg/hooks/audit/run.sh"),
637                ],
638                tmp.path(),
639            )
640            .unwrap();
641
642        adapter
643            .remove_config_entries(
644                &[
645                    "mcp:to-remove".to_string(),
646                    "hook:tool.pre:audit".to_string(),
647                ],
648                tmp.path(),
649            )
650            .unwrap();
651
652        let raw_toml = std::fs::read_to_string(tmp.path().join(CODEX_CONFIG_TOML)).unwrap();
653        let toml: TomlValue = toml::from_str(&raw_toml).unwrap();
654        assert!(toml["mcp"]["servers"].get("to-remove").is_none());
655        assert!(toml["mcp"]["servers"]["to-keep"].is_table());
656
657        let raw_hooks = std::fs::read_to_string(tmp.path().join("codex_hooks.json")).unwrap();
658        let json: serde_json::Value = serde_json::from_str(&raw_hooks).unwrap();
659        assert!(json["hooks"]["pre-exec"].as_array().unwrap().is_empty());
660        assert_eq!(json["hooks"]["post-exec"].as_array().unwrap().len(), 1);
661    }
662
663    #[test]
664    fn remove_hook_entries_match_windows_backslash_paths_without_partial_name_collision() {
665        let tmp = TempDir::new().unwrap();
666        let existing = serde_json::json!({
667            "hooks": {
668                "pre-exec": [
669                    r#"bash "C:\\pkg\\hooks\\audit\\run.sh""#,
670                    r#"bash "C:\\pkg\\hooks\\audit-extended\\run.sh""#
671                ]
672            }
673        });
674        std::fs::write(
675            tmp.path().join("codex_hooks.json"),
676            serde_json::to_string_pretty(&existing).unwrap(),
677        )
678        .unwrap();
679
680        remove_codex_hook_entries(&["hook:tool.pre:audit".to_string()], tmp.path()).unwrap();
681
682        let raw = std::fs::read_to_string(tmp.path().join("codex_hooks.json")).unwrap();
683        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
684        let hooks = json["hooks"]["pre-exec"].as_array().unwrap();
685        assert_eq!(hooks.len(), 1);
686        assert!(hooks[0].as_str().unwrap().contains("audit-extended"));
687    }
688}