File size: 32,708 Bytes
34c3ee8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
"""Migrate Hermes' MCP server config and Codex's installed curated plugins

to the format Codex expects in ~/.codex/config.toml.



When the user enables the codex_app_server runtime, the codex subprocess

runs its own MCP client and its own plugin runtime (Linear, Atlassian,

Asana, plus per-account ChatGPT apps via app/list). For both of those to

be useful, the user's choices need to be visible to codex too. This

module:



  1. Reads Hermes' YAML and writes equivalent [mcp_servers.<name>]

     entries to ~/.codex/config.toml.

  2. Queries codex's `plugin/list` for the openai-curated marketplace

     and writes [plugins."<name>@<marketplace>"] entries for any plugin

     the user has installed=true on their codex CLI. (This is what

     OpenClaw calls "migrate native codex plugins" β€” the YouTube-video-

     worthy bit Pash highlighted: Canva, GitHub, Calendar, Gmail

     pre-configured.)

  3. Writes a [permissions] default profile so users on this runtime

     don't get an approval prompt on every write attempt.



What translates (MCP servers):

  Hermes mcp_servers.<n>.command/args/env  β†’ codex stdio transport

  Hermes mcp_servers.<n>.url/headers       β†’ codex streamable_http transport

  Hermes mcp_servers.<n>.timeout           β†’ codex tool_timeout_sec

  Hermes mcp_servers.<n>.connect_timeout   β†’ codex startup_timeout_sec



What does NOT translate (warned + skipped):

  Hermes-specific keys (sampling, etc.) β€” codex's MCP client has no

  equivalent. Listed in the per-server skipped[] field of the report.



What's NOT migrated (intentional):

  AGENTS.md β€” codex respects this file natively in its cwd. Hermes' own

  AGENTS.md (project-level) is already in the worktree, so codex picks

  it up without translation. No code needed.

"""

from __future__ import annotations

import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional

logger = logging.getLogger(__name__)


# Marker comments wrapping the managed section so re-runs can detect
# what's ours and what's user-edited. Both must appear or strip is a no-op.
MIGRATION_MARKER = (
    "# managed by hermes-agent β€” `hermes codex-runtime migrate` regenerates this section"
)
MIGRATION_END_MARKER = (
    "# end hermes-agent managed section"
)


@dataclass
class MigrationReport:
    """Outcome of a migration pass."""

    target_path: Optional[Path] = None
    migrated: list[str] = field(default_factory=list)
    skipped_keys_per_server: dict[str, list[str]] = field(default_factory=dict)
    migrated_plugins: list[str] = field(default_factory=list)
    plugin_query_error: Optional[str] = None
    wrote_permissions_default: Optional[str] = None
    errors: list[str] = field(default_factory=list)
    written: bool = False
    dry_run: bool = False

    def summary(self) -> str:
        lines = []
        if self.dry_run:
            lines.append(f"(dry run) Would write {self.target_path}")
        elif self.written:
            lines.append(f"Wrote {self.target_path}")
        if self.migrated:
            lines.append(f"Migrated {len(self.migrated)} MCP server(s):")
            for name in self.migrated:
                skipped = self.skipped_keys_per_server.get(name, [])
                note = (
                    f" (skipped: {', '.join(skipped)})" if skipped else ""
                )
                lines.append(f"  - {name}{note}")
        else:
            lines.append("No MCP servers found in Hermes config.")
        if self.migrated_plugins:
            lines.append(
                f"Migrated {len(self.migrated_plugins)} native Codex plugin(s):"
            )
            for name in self.migrated_plugins:
                lines.append(f"  - {name}")
        elif self.plugin_query_error:
            lines.append(f"Codex plugin discovery skipped: {self.plugin_query_error}")
        if self.wrote_permissions_default:
            lines.append(
                f"Wrote default_permissions = "
                f"{self.wrote_permissions_default!r}"
            )
        for err in self.errors:
            lines.append(f"⚠ {err}")
        return "\n".join(lines)


# Hermes keys that codex's MCP schema doesn't support β€” dropped during
# migration with a warning. Anything not on the keep list AND not the
# transport keys is added to skipped.
_KNOWN_HERMES_KEYS = {
    # transport β€” stdio
    "command", "args", "env", "cwd",
    # transport β€” http
    "url", "headers", "transport",
    # timeouts
    "timeout", "connect_timeout",
    # general
    "enabled", "description",
}

# Subset that have a direct codex equivalent.
_KEYS_DROPPED_WITH_WARNING = {
    # Hermes' sampling subsection β€” codex MCP has no equivalent
    "sampling",
}


def _translate_one_server(

    name: str, hermes_cfg: dict

) -> tuple[Optional[dict], list[str]]:
    """Translate one Hermes MCP server config to the codex inline-table dict

    representation. Returns (codex_entry, skipped_keys).



    codex_entry is a dict ready for TOML serialization, or None when the

    server can't be translated (e.g. neither command nor url present)."""
    if not isinstance(hermes_cfg, dict):
        return None, []

    skipped: list[str] = []
    out: dict[str, Any] = {}

    has_command = bool(hermes_cfg.get("command"))
    has_url = bool(hermes_cfg.get("url"))

    if has_command and has_url:
        skipped.append("url (both command and url set; preferring stdio)")
        has_url = False

    if has_command:
        # Stdio transport
        out["command"] = str(hermes_cfg["command"])
        args = hermes_cfg.get("args") or []
        if args:
            out["args"] = [str(a) for a in args]
        env = hermes_cfg.get("env") or {}
        if env:
            # Codex expects string values
            out["env"] = {str(k): str(v) for k, v in env.items()}
        cwd = hermes_cfg.get("cwd")
        if cwd:
            out["cwd"] = str(cwd)
    elif has_url:
        # streamable_http transport (codex covers both http and SSE here)
        out["url"] = str(hermes_cfg["url"])
        headers = hermes_cfg.get("headers") or {}
        if headers:
            out["http_headers"] = {str(k): str(v) for k, v in headers.items()}
        # Hermes' transport: sse hint is informational; codex auto-negotiates
        if hermes_cfg.get("transport") == "sse":
            skipped.append("transport=sse (codex auto-negotiates)")
    else:
        return None, ["no command or url field"]

    # Timeouts
    if "timeout" in hermes_cfg:
        try:
            out["tool_timeout_sec"] = float(hermes_cfg["timeout"])
        except (TypeError, ValueError):
            skipped.append("timeout (not numeric)")
    if "connect_timeout" in hermes_cfg:
        try:
            out["startup_timeout_sec"] = float(hermes_cfg["connect_timeout"])
        except (TypeError, ValueError):
            skipped.append("connect_timeout (not numeric)")

    # Enabled flag (codex defaults to true so we only emit when explicitly false)
    if hermes_cfg.get("enabled") is False:
        out["enabled"] = False

    # Detect keys we explicitly drop with warning
    for key in hermes_cfg:
        if key in _KEYS_DROPPED_WITH_WARNING:
            skipped.append(f"{key} (no codex equivalent)")
        elif key not in _KNOWN_HERMES_KEYS:
            skipped.append(f"{key} (unknown Hermes key)")

    return out, skipped


def _format_toml_value(value: Any) -> str:
    """Minimal TOML value formatter for the value types we emit.



    We only emit strings, numbers, booleans, and tables of those β€” no nested

    arrays of tables. This covers everything codex's MCP schema accepts."""
    if isinstance(value, bool):
        return "true" if value else "false"
    if isinstance(value, (int, float)):
        return repr(value)
    if isinstance(value, str):
        # Escape per TOML basic-string rules. Order matters: backslash
        # first so the other escapes don't get re-escaped.
        # Control characters (newline, tab, etc.) must use \-escapes
        # because TOML basic strings don't allow literal control chars
        # β€” passing them through would produce invalid TOML that codex
        # would refuse to load. Paths usually don't contain control
        # chars but env-var passthrough (HERMES_HOME, PYTHONPATH) could
        # in pathological cases.
        escaped = (
            value
            .replace("\\", "\\\\")
            .replace('"', '\\"')
            .replace("\b", "\\b")
            .replace("\t", "\\t")
            .replace("\n", "\\n")
            .replace("\f", "\\f")
            .replace("\r", "\\r")
        )
        return f'"{escaped}"'
    if isinstance(value, list):
        items = ", ".join(_format_toml_value(v) for v in value)
        return f"[{items}]"
    if isinstance(value, dict):
        items = ", ".join(
            f'{_quote_key(k)} = {_format_toml_value(v)}' for k, v in value.items()
        )
        return "{ " + items + " }" if items else "{}"
    raise ValueError(f"Unsupported TOML value type: {type(value).__name__}")


def _quote_key(key: str) -> str:
    """Return key bare-or-quoted depending on whether it's a valid bare key."""
    if all(c.isalnum() or c in "-_" for c in key) and key:
        return key
    escaped = key.replace("\\", "\\\\").replace('"', '\\"')
    return f'"{escaped}"'

def render_codex_toml_section(

    servers: dict[str, dict],

    plugins: Optional[list[dict]] = None,

    default_permission_profile: Optional[str] = None,

) -> str:
    """Render the managed [mcp_servers.<n>] / [plugins.<id>] / [permissions]

    block for ~/.codex/config.toml.



    Args:

        servers: dict of MCP server name β†’ translated codex inline-table

        plugins: optional list of {name, marketplace, enabled} for native

            Codex plugins to enable. (E.g. the Linear / Atlassian / Asana

            curated plugins, or per-account ChatGPT apps.)

        default_permission_profile: when set, write `[permissions] default`

            so the user doesn't get an approval prompt on every write

            attempt. Common values: "workspace-write", "read-only",

            "full-access".

    """
    out = [MIGRATION_MARKER]
    if not servers and not plugins and not default_permission_profile:
        out.append("# (no MCP servers, plugins, or permissions configured by Hermes)")
        out.append(MIGRATION_END_MARKER)
        return "\n".join(out) + "\n"

    if default_permission_profile:
        # Codex's config schema: `default_permissions` is a top-level
        # string referencing a profile name. Built-in profile names start
        # with ":" (":workspace-write", ":read-only", ":full-access"). The
        # [permissions] table is for *user-defined* named profiles with
        # structured fields β€” not what we want.
        normalized = (
            default_permission_profile
            if default_permission_profile.startswith(":")
            else f":{default_permission_profile}"
        )
        out.append("")
        out.append(f"default_permissions = {_format_toml_value(normalized)}")

    if servers:
        for name in sorted(servers.keys()):
            cfg = servers[name]
            out.append("")
            out.append(f"[mcp_servers.{_quote_key(name)}]")
            for k, v in cfg.items():
                out.append(f"{_quote_key(k)} = {_format_toml_value(v)}")

    if plugins:
        for plugin in sorted(plugins, key=lambda p: f"{p.get('name','')}@{p.get('marketplace','')}"):
            name = plugin.get("name") or ""
            marketplace = plugin.get("marketplace") or "openai-curated"
            enabled = bool(plugin.get("enabled", True))
            qualified = f"{name}@{marketplace}"
            out.append("")
            out.append(f'[plugins.{_quote_key(qualified)}]')
            out.append(f"enabled = {_format_toml_value(enabled)}")

    out.append("")
    out.append(MIGRATION_END_MARKER)
    return "\n".join(out) + "\n"


def _insert_managed_block_at_top_level(user_text: str, managed_block: str) -> str:
    """Insert Hermes' managed Codex TOML block while keeping root keys root-scoped.



    TOML has no syntax to return to the document root after a table header.

    Therefore appending a root key like `default_permissions = ...` after a

    user table such as `[features]` actually creates `features.default_permissions`,

    which Codex rejects. Insert the managed block before the first table header

    so its root keys remain top-level, while preserving user content verbatim.

    """
    if not user_text.strip():
        return managed_block

    lines = user_text.splitlines(keepends=True)
    first_table_idx: Optional[int] = None
    for idx, line in enumerate(lines):
        stripped = line.lstrip()
        if stripped.startswith("["):
            first_table_idx = idx
            break

    if first_table_idx is None:
        prefix = user_text.rstrip("\n")
        return f"{prefix}\n\n{managed_block}" if prefix else managed_block

    prefix = "".join(lines[:first_table_idx]).rstrip("\n")
    suffix = "".join(lines[first_table_idx:]).lstrip("\n")
    if prefix:
        return f"{prefix}\n\n{managed_block}\n{suffix}"
    return f"{managed_block}\n{suffix}"


def _strip_unmanaged_plugin_tables(toml_text: str) -> str:
    """Remove ``[plugins."<name>@<marketplace>"]`` tables that live OUTSIDE the

    managed block.



    Codex itself writes these tables when the user runs ``codex plugins enable``

    directly (i.e. before Hermes' migrate has ever touched the file). When we

    later run migrate, ``_query_codex_plugins()`` reports the same plugins via

    the live ``plugin/list`` RPC and we re-emit them inside the managed block.

    The result without this strip is duplicate ``[plugins."X@Y"]`` table

    headers β€” codex's strict TOML parser then refuses to load the file.



    We own the ``[plugins.*]`` namespace once migrate has run, so dropping any

    pre-existing ``[plugins.*]`` tables is safe: ``plugin/list`` is the source

    of truth for what's actually installed. The caller is expected to only

    invoke this strip when ``plugin/list`` succeeded β€” otherwise we'd lose

    plugins the user installed via ``codex`` without a way to re-emit them.



    Behavior:

      * Lines beginning with ``[plugins.`` start a swallow region that ends at

        the next non-``[plugins.`` table header or end-of-file.

      * Content inside the managed block is untouched (callers should run

        ``_strip_existing_managed_block`` first so the managed block has

        already been removed when this runs).

    """
    lines = toml_text.splitlines(keepends=True)
    out: list[str] = []
    in_plugin_table = False
    for line in lines:
        stripped = line.lstrip()
        # Only treat a line as a table header when it has the shape
        # ``[...]`` (optionally followed by a comment). Multi-line array
        # continuations like ``["nested"],`` also start with ``[`` after
        # lstrip but are not headers β€” without this guard they would
        # falsely flip ``in_plugin_table`` to False mid-table and leak
        # array fragments into the output.
        if _looks_like_table_header(stripped):
            in_plugin_table = stripped.startswith("[plugins.")
            if in_plugin_table:
                continue
        if in_plugin_table:
            # Swallow keys/comments/blanks until the next table header.
            continue
        out.append(line)
    return "".join(out)


def _looks_like_table_header(stripped_line: str) -> bool:
    """Return True if ``stripped_line`` is a TOML table header.



    A header has the shape ``[name]`` or ``[[name]]`` (array-of-tables),

    optionally followed by a comment. The closing ``]`` (or ``]]``) must

    appear on the same line, and no key-assignment ``=`` can precede it.

    This distinguishes real headers from multi-line array continuation

    lines that also start with ``[`` after ``lstrip()``.

    """
    if not stripped_line.startswith("["):
        return False
    # Drop trailing comment so e.g. ``[features]  # note`` still matches.
    head = stripped_line.split("#", 1)[0].rstrip()
    if not head.endswith("]"):
        return False
    # ``key = [x]`` would have an ``=`` before the bracket; a header doesn't.
    bracket_idx = head.index("]")
    return "=" not in head[: bracket_idx + 1]


def _strip_existing_managed_block(toml_text: str) -> str:
    """Remove any prior managed section so re-runs idempotently replace it.



    The managed section is everything between MIGRATION_MARKER (start) and

    MIGRATION_END_MARKER (end), inclusive of both markers. User-edited

    sections above or below are preserved verbatim.



    Backward compatibility: if the start marker is found but no end marker

    follows, we fall back to the heuristic that swallows lines until we

    hit a section that's not [mcp_servers.*]/[plugins.*]/[permissions]/

    a `default_permissions =` key. This matches what older versions of

    this code wrote so re-runs don't break configs from prior Hermes

    versions."""
    lines = toml_text.splitlines(keepends=True)
    out: list[str] = []
    in_managed = False
    saw_end_marker = False
    for line in lines:
        line_stripped_nl = line.rstrip("\n")
        if line_stripped_nl == MIGRATION_MARKER:
            in_managed = True
            saw_end_marker = False
            continue
        if in_managed:
            if line_stripped_nl == MIGRATION_END_MARKER:
                in_managed = False
                saw_end_marker = True
                continue
            stripped = line.lstrip()
            if not saw_end_marker and stripped.startswith("[") and not (
                stripped.startswith("[mcp_servers")
                or stripped.startswith("[plugins")
                or stripped.startswith("[permissions]")
                or stripped.startswith("[permissions.")
            ):
                # Old-format managed block without end marker: bail back
                # to user content as soon as we see a non-managed section.
                in_managed = False
                out.append(line)
                continue
            # Otherwise swallow the line.
            continue
        out.append(line)
    return "".join(out)


def _query_codex_plugins(

    codex_home: Optional[Path] = None,

    timeout: float = 8.0,

) -> tuple[list[dict], Optional[str]]:
    """Query codex's `plugin/list` for installed curated plugins.



    Spawns `codex app-server` briefly, sends initialize + plugin/list,

    extracts plugins where installed=true. Returns (plugins, error).

    Plugins is a list of {name, marketplace, enabled} dicts ready for

    render_codex_toml_section().



    On any failure (codex not installed, RPC error, timeout) returns

    ([], error_message). Migration treats this as non-fatal β€” MCP

    servers and permissions still write through.

    """
    try:
        from agent.transports.codex_app_server import CodexAppServerClient
    except Exception as exc:
        return [], f"transport unavailable: {exc}"

    try:
        with CodexAppServerClient(
            codex_home=str(codex_home) if codex_home else None
        ) as client:
            client.initialize(client_name="hermes-migration")
            resp = client.request("plugin/list", {}, timeout=timeout)
    except Exception as exc:
        return [], f"plugin/list query failed: {exc}"

    out: list[dict] = []
    seen: set[tuple[str, str]] = set()
    marketplaces = resp.get("marketplaces") or []
    if not isinstance(marketplaces, list):
        return [], "plugin/list response missing 'marketplaces'"
    for marketplace in marketplaces:
        if not isinstance(marketplace, dict):
            continue
        market_name = str(marketplace.get("name") or "openai-curated")
        plugins = marketplace.get("plugins") or []
        if not isinstance(plugins, list):
            continue
        for plugin in plugins:
            if not isinstance(plugin, dict):
                continue
            installed = bool(plugin.get("installed", False))
            if not installed:
                continue
            # Skip plugins codex itself reports as unavailable (broken
            # install, missing OAuth, removed from marketplace, etc.).
            # Cf. openclaw/openclaw#80815 β€” OpenClaw learned to gate
            # migration on app readiness to avoid writing config that
            # would fail at activation time. Our migration writes to
            # codex's config.toml directly, so a broken plugin would
            # surface as a codex error on first use. Skipping it here
            # keeps the migrated config clean and the user's first
            # codex turn from failing.
            availability = str(plugin.get("availability") or "").upper()
            if availability and availability != "AVAILABLE":
                logger.debug(
                    "skipping plugin %s: availability=%s",
                    plugin.get("name"), availability,
                )
                continue
            name = str(plugin.get("name") or "")
            if not name:
                continue
            key = (name, market_name)
            if key in seen:
                continue
            seen.add(key)
            # Carry forward whatever 'enabled' codex reports β€” defaults to
            # true for installed plugins. This is the same shape OpenClaw
            # writes when migrating native codex plugins.
            out.append({
                "name": name,
                "marketplace": market_name,
                "enabled": bool(plugin.get("enabled", True)),
            })
    return out, None


def _looks_like_test_tempdir(path: str) -> bool:
    """Heuristic: does ``path`` look like a pytest/transient tempdir?



    pytest tempdirs live under ``pytest-of-<user>/pytest-<n>/`` (created via

    ``tmp_path`` / ``tmp_path_factory``) and are reaped between sessions.

    macOS routes ``/tmp`` through ``/private/var/folders/<…>/T`` which is

    what pytest's tempdir factory uses by default. If a HERMES_HOME pointing

    at one of those paths is burned into ``~/.codex/config.toml``, every

    codex-routed hermes-tools call fails silently once the directory is GC'd.



    We err on the side of refusing β€” losing a (very unlikely) real

    ``~/.hermes`` symlink that happens to live under ``/private/var/folders``

    is much less harmful than silently bricking codex's tool surface.

    """
    if not path:
        return False
    needles = (
        "pytest-of-",
        "/pytest-",
        "/tmp/pytest",
        "/private/var/folders/",  # macOS tempdir root
    )
    normalized = path.lower()
    return any(needle in normalized for needle in needles)


def _build_hermes_tools_mcp_entry() -> dict:
    """Build the codex stdio-transport entry that launches Hermes' own

    tool surface as an MCP server. Codex's subprocess will call back into

    this for browser/web/delegate_task/vision/memory/skills tools.



    The command runs the worktree's Python via the current sys.executable

    so a hermes installed under /opt/, /usr/local/, or a venv all work.

    HERMES_HOME and PYTHONPATH are passed through so the spawned process

    sees the same config + module layout the user is running."""
    import sys

    env: dict[str, str] = {}
    # HERMES_HOME passes through IF SET so the MCP subprocess sees the same
    # config / auth / sessions DB as the parent CLI. Read from os.environ
    # (not get_hermes_home()) on purpose: when the env var is unset we want
    # codex's subprocess to inherit whatever HERMES_HOME its launcher sets
    # at runtime (systemd unit, gateway, kanban dispatcher, custom shell),
    # rather than burning the migrate-time resolved default into config.toml
    # β€” that would override the launcher's HERMES_HOME and pin the subprocess
    # to the wrong profile.
    #
    # The pytest-tempdir guard below catches the issue #26250 Bug C scenario:
    # a sibling test's monkeypatch.setenv("HERMES_HOME", tmp_path) would
    # otherwise leak a transient pytest tempdir into the user's real
    # ~/.codex/config.toml and silently brick codex once the tempdir is GC'd.
    hermes_home = os.environ.get("HERMES_HOME") or ""
    if hermes_home and _looks_like_test_tempdir(hermes_home):
        hermes_home = ""
    if hermes_home:
        env["HERMES_HOME"] = hermes_home
    # PYTHONPATH passes through so a worktree-launched hermes finds the
    # branch's modules instead of the installed package.
    pythonpath = os.environ.get("PYTHONPATH")
    if pythonpath:
        env["PYTHONPATH"] = pythonpath
    # Quiet mode + redaction defaults so the MCP wire stays clean.
    env["HERMES_QUIET"] = "1"
    env["HERMES_REDACT_SECRETS"] = env.get("HERMES_REDACT_SECRETS", "true")

    out: dict[str, Any] = {
        "command": sys.executable,
        "args": ["-m", "agent.transports.hermes_tools_mcp_server"],
    }
    if env:
        out["env"] = env
    # Generous timeouts β€” browser_navigate or delegate_task can take a
    # while; we don't want codex's MCP client to give up too early.
    out["startup_timeout_sec"] = 30.0
    out["tool_timeout_sec"] = 600.0
    return out


def migrate(

    hermes_config: dict,

    *,

    codex_home: Optional[Path] = None,

    dry_run: bool = False,

    discover_plugins: bool = True,

    default_permission_profile: Optional[str] = ":workspace",

    expose_hermes_tools: bool = True,

) -> MigrationReport:
    """Translate Hermes mcp_servers config + Codex curated plugins into

    ~/.codex/config.toml.



    Args:

        hermes_config: full ~/.hermes/config.yaml dict

        codex_home: override CODEX_HOME (defaults to ~/.codex)

        dry_run: skip the actual write; report what would happen

        discover_plugins: when True (default), query `plugin/list` against

            the live codex CLI to migrate any installed curated plugins

            into [plugins."<name>@<marketplace>"] entries. Set False to

            skip the subprocess spawn (for tests or restricted environments).

        default_permission_profile: when set (default ":workspace"), write

            top-level `default_permissions = "<name>"` so users on this

            runtime don't get an approval prompt on every write attempt.

            Built-in codex profile names are ":workspace", ":read-only",

            ":danger-no-sandbox" (note the leading ":"). Also accepts a

            user-defined profile name (no leading ":") that the user has

            configured in their own [permissions.<name>] table. Set None

            to leave permissions unset and let codex use its compiled-in

            default (which is read-only).

        expose_hermes_tools: when True (default), register Hermes' own

            tool surface (web_search, browser_*, delegate_task, vision,

            memory, skills, etc.) as an MCP server in ~/.codex/config.toml

            so the codex subprocess can call back into Hermes for tools

            codex doesn't have built in. Set False to opt out.

    """
    report = MigrationReport(dry_run=dry_run)
    codex_home = codex_home or Path.home() / ".codex"
    target = codex_home / "config.toml"
    report.target_path = target

    hermes_servers = (hermes_config or {}).get("mcp_servers") or {}
    if not isinstance(hermes_servers, dict):
        report.errors.append(
            "mcp_servers in Hermes config is not a dict; cannot migrate."
        )
        return report

    translated: dict[str, dict] = {}
    for name, cfg in hermes_servers.items():
        out, skipped = _translate_one_server(str(name), cfg or {})
        if out is None:
            report.errors.append(
                f"server {name!r} skipped: {', '.join(skipped) or 'no transport configured'}"
            )
            continue
        translated[str(name)] = out
        if skipped:
            report.skipped_keys_per_server[str(name)] = skipped
        report.migrated.append(str(name))

    # Discover installed Codex curated plugins. Best-effort β€” never blocks
    # the migration if codex is unreachable or the RPC fails.
    plugins: list[dict] = []
    plugin_query_succeeded = False
    if discover_plugins and not dry_run:
        plugins, plugin_err = _query_codex_plugins(codex_home=codex_home)
        if plugin_err:
            report.plugin_query_error = plugin_err
        else:
            # plugin/list returned authoritatively (even if the list is empty).
            # That means we own [plugins.*] for this re-render and can safely
            # strip any pre-existing tables outside the managed block.
            plugin_query_succeeded = True
        for p in plugins:
            report.migrated_plugins.append(f"{p['name']}@{p['marketplace']}")

    # Track whether we wrote a default permission profile so the report
    # surfaces it to the user.
    if default_permission_profile:
        report.wrote_permissions_default = default_permission_profile

    # Inject Hermes' own tool surface as an MCP server so the spawned
    # codex subprocess can call back into Hermes for the tools codex
    # doesn't ship with β€” web_search, browser_*, delegate_task, vision,
    # memory, skills, session_search, image_generate, text_to_speech.
    # The server itself is agent/transports/hermes_tools_mcp_server.py
    # and is launched on demand by codex (stdio MCP).
    if expose_hermes_tools:
        translated["hermes-tools"] = _build_hermes_tools_mcp_entry()
        if "hermes-tools" not in report.migrated:
            report.migrated.append("hermes-tools")

    # Build the new managed block
    managed_block = render_codex_toml_section(
        translated, plugins=plugins,
        default_permission_profile=default_permission_profile,
    )

    # Read existing codex config if any, strip the prior managed block,
    # append the new one.
    if target.exists():
        try:
            existing = target.read_text(encoding="utf-8")
        except Exception as exc:
            report.errors.append(f"could not read {target}: {exc}")
            return report
        without_managed = _strip_existing_managed_block(existing)
        # Bug B: when plugin/list ran authoritatively, codex's own
        # [plugins."<name>@<marketplace>"] tables outside our managed block
        # would survive _strip_existing_managed_block and then collide with
        # the entries we re-emit inside the managed block β€” producing
        # duplicate-table-header parse errors on codex's next startup. Drop
        # those pre-existing tables since plugin/list is the source of truth.
        if plugin_query_succeeded:
            without_managed = _strip_unmanaged_plugin_tables(without_managed)
        new_text = _insert_managed_block_at_top_level(without_managed, managed_block)
    else:
        new_text = managed_block

    if dry_run:
        return report

    try:
        codex_home.mkdir(parents=True, exist_ok=True)
        # Atomic write: write to a temp file in the same directory then
        # rename. Same-directory rename is atomic on POSIX and ReplaceFile
        # on Windows. Avoids leaving a half-written config.toml that
        # codex would refuse to load if we crash mid-write.
        import tempfile
        tmp_fd, tmp_path_str = tempfile.mkstemp(
            prefix=".config.toml.", dir=str(codex_home)
        )
        tmp_path = Path(tmp_path_str)
        try:
            with os.fdopen(tmp_fd, "w", encoding="utf-8") as fh:
                fh.write(new_text)
            tmp_path.replace(target)
        except Exception:
            # Clean up the temp file if the rename didn't happen.
            try:
                if tmp_path.exists():
                    tmp_path.unlink()
            except Exception:
                pass
            raise
        report.written = True
    except Exception as exc:
        report.errors.append(f"could not write {target}: {exc}")
    return report