agentshim 0.6.0__tar.gz → 0.6.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {agentshim-0.6.0 → agentshim-0.6.2}/CHANGELOG.md +59 -0
  2. {agentshim-0.6.0 → agentshim-0.6.2}/PKG-INFO +1 -1
  3. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/__init__.py +1 -1
  4. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/core/mcp.py +20 -1
  5. agentshim-0.6.2/agentshim/core/profile.py +89 -0
  6. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/__init__.py +41 -0
  7. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/claude/__init__.py +2 -1
  8. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/claude/provider.py +15 -0
  9. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/claude/scripted.py +21 -0
  10. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/codex/__init__.py +4 -2
  11. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/codex/provider.py +111 -3
  12. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/codex/scripted.py +19 -0
  13. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/copilot/__init__.py +4 -2
  14. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/copilot/provider.py +52 -1
  15. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/copilot/scripted.py +19 -0
  16. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/gemini/__init__.py +2 -1
  17. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/gemini/provider.py +15 -2
  18. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/gemini/scripted.py +21 -0
  19. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/opencode/__init__.py +2 -1
  20. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/opencode/provider.py +15 -0
  21. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/opencode/scripted.py +20 -0
  22. agentshim-0.6.2/agentshim/testing/__init__.py +319 -0
  23. {agentshim-0.6.0 → agentshim-0.6.2}/pyproject.toml +1 -1
  24. agentshim-0.6.0/agentshim/core/profile.py +0 -57
  25. agentshim-0.6.0/agentshim/testing/__init__.py +0 -174
  26. {agentshim-0.6.0 → agentshim-0.6.2}/.gitignore +0 -0
  27. {agentshim-0.6.0 → agentshim-0.6.2}/README.md +0 -0
  28. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/agent.py +0 -0
  29. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/core/__init__.py +0 -0
  30. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/core/_files.py +0 -0
  31. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/core/env.py +0 -0
  32. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/core/errors.py +0 -0
  33. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/core/events.py +0 -0
  34. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/core/provider.py +0 -0
  35. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/core/schema.py +0 -0
  36. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/core/stream.py +0 -0
  37. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/core/turn.py +0 -0
  38. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/core/usage.py +0 -0
  39. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/execution/__init__.py +0 -0
  40. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/execution/executor.py +0 -0
  41. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/execution/host.py +0 -0
  42. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/execution/transform.py +0 -0
  43. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/claude/events.py +0 -0
  44. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/claude/hooks/__init__.py +0 -0
  45. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
  46. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/claude/parser.py +0 -0
  47. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/claude/sandbox.py +0 -0
  48. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/codex/events.py +0 -0
  49. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/codex/parser.py +0 -0
  50. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/copilot/events.py +0 -0
  51. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/copilot/parser.py +0 -0
  52. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/gemini/events.py +0 -0
  53. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/gemini/parser.py +0 -0
  54. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/opencode/events.py +0 -0
  55. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/opencode/parser.py +0 -0
  56. {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/py.typed +0 -0
@@ -1,5 +1,64 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.2 (2026-09-26)
4
+
5
+ - Add optional positive finite `tool_timeout_s` to stdio and HTTP MCP server
6
+ specs. Codex renders it as an invocation-scoped `tool_timeout_sec` config
7
+ override; providers that cannot represent it reject the request explicitly.
8
+
9
+ ## 0.6.1 (2026-09-10)
10
+
11
+ Additive only: every new field defaults, so a 0.6.0 consumer's existing
12
+ `ProviderProfile` construction and every shipped provider keep working
13
+ unchanged.
14
+
15
+ `ProviderProfile` gains four fields:
16
+
17
+ - `container_env: Mapping[str, str]` (default empty): environment a CLI
18
+ needs to run as root in a container, beyond auth. Claude Code sets
19
+ `IS_SANDBOX=1`, which it requires before accepting
20
+ `--dangerously-skip-permissions` as root; the other four providers need
21
+ nothing extra.
22
+ - `state_root_env: str | None` (default `None`): the CLI's own documented
23
+ variable for relocating its primary state directory (`state_dirs[0]`).
24
+ `CLAUDE_CONFIG_DIR` for Claude, `CODEX_HOME` for Codex, `COPILOT_HOME` for
25
+ Copilot; `None` for Gemini and opencode, which document no such variable.
26
+ - `auth_files: tuple[str, ...]` (default empty): home-relative paths to the
27
+ files that hold credentials and user configuration, the minimal set to
28
+ copy into another environment so the CLI is already logged in. Every entry
29
+ lies inside a `state_dirs` entry.
30
+ - `mcp_config_file: str | None` (default `None`): for a `CONFIG_FILE`
31
+ provider, the workspace-relative path its MCP config is written to for a
32
+ turn (`.mcp.json`, `.gemini/settings.json`, `opencode.json`). The provider
33
+ module defines it as the same constant `install_mcp` writes to, so the two
34
+ cannot drift. `None` for `CLI_FLAGS`/`NONE` providers.
35
+
36
+ `tests/unit/providers/test_conventions.py` pins these across every provider:
37
+ `auth_files` entries lie inside `state_dirs`, `mcp_config_file` is set
38
+ exactly when `mcp` is `CONFIG_FILE` and matches where `install_mcp` actually
39
+ writes, `state_root_env` is uppercase when set, and `container_env` keys are
40
+ uppercase.
41
+
42
+ ### Added
43
+
44
+ - `agentshim.testing.scripted_resume_failure(provider, *, session_id=None)`:
45
+ a `FakeRun` that fails the way *provider* recognises a lost resumed
46
+ conversation, so a consumer can test `SessionResumeError` handling without
47
+ knowing any provider's wire format. Backed by a required
48
+ `resume_failure_lines(...)` entry in every `providers/<name>/scripted.py`,
49
+ found through `providers.get_resume_failure_lines(name)` the same way
50
+ `scripted_turn` finds `scripted_lines`, so a new provider cannot ship
51
+ without one.
52
+ - `agentshim.testing.installed_mcp_servers(provider, request, workspace)`:
53
+ the MCP servers one turn installed, keyed by server name, each normalized
54
+ to `command`/`args`/`env` (stdio) or `url`/`transport` (HTTP) regardless of
55
+ how the provider itself renders it. Reads the config file for a
56
+ CONFIG_FILE provider and parses `request.argv` for a CLI_FLAGS one, via a
57
+ `parse_mcp_servers` kept next to the renderer it inverts
58
+ (`providers/codex/provider.py`, `providers/copilot/provider.py`). Meant to
59
+ be called from inside a `FakeExecutor` `run` callback: a CONFIG_FILE
60
+ provider's config exists only for the lifetime of the turn.
61
+
3
62
  ## 0.6.0
4
63
 
5
64
  A restructure, not an upgrade. 0.6 replaces the whole public API of 0.5 and
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentshim
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: Provider-agnostic coding-agent CLI shims
5
5
  Requires-Python: >=3.10
6
6
  Provides-Extra: test
@@ -84,7 +84,7 @@ from .providers.copilot import CopilotProvider
84
84
  from .providers.gemini import GeminiProvider
85
85
  from .providers.opencode import OpencodeProvider
86
86
 
87
- __version__ = "0.6.0"
87
+ __version__ = "0.6.2"
88
88
 
89
89
  __all__ = [
90
90
  "AgentEvent",
@@ -14,6 +14,7 @@ each concurrent turn its own workspace.
14
14
  from __future__ import annotations
15
15
 
16
16
  import json
17
+ import math
17
18
  from dataclasses import dataclass, field
18
19
  from types import MappingProxyType
19
20
  from typing import TYPE_CHECKING, Any, Literal, cast
@@ -37,12 +38,18 @@ def _no_env() -> Mapping[str, str]:
37
38
 
38
39
  @dataclass(frozen=True)
39
40
  class StdioMcpServer:
40
- """MCP server launched as a subprocess over stdio."""
41
+ """MCP server launched as a subprocess over stdio.
42
+
43
+ ``tool_timeout_s`` is the maximum time one tool call may run. Providers
44
+ that cannot express a per-server tool timeout reject a non-``None`` value
45
+ rather than silently dropping it.
46
+ """
41
47
 
42
48
  name: str
43
49
  command: str
44
50
  args: Sequence[str] = ()
45
51
  env: Mapping[str, str] = field(default_factory=_no_env)
52
+ tool_timeout_s: float | None = None
46
53
 
47
54
  def __post_init__(self) -> None:
48
55
  """Reject a spec the provider could never launch.
@@ -56,6 +63,7 @@ class StdioMcpServer:
56
63
  if not self.command:
57
64
  msg = f"MCP server {self.name!r} must declare a command"
58
65
  raise ValueError(msg)
66
+ _validate_tool_timeout(self.name, self.tool_timeout_s)
59
67
 
60
68
 
61
69
  #: The two remote MCP transports the CLIs distinguish.
@@ -79,6 +87,7 @@ class HttpMcpServer:
79
87
  url: str
80
88
  headers: Mapping[str, str] = field(default_factory=_no_env)
81
89
  transport: McpTransport = "http"
90
+ tool_timeout_s: float | None = None
82
91
 
83
92
  def __post_init__(self) -> None:
84
93
  """Reject a spec the provider could never reach.
@@ -95,6 +104,16 @@ class HttpMcpServer:
95
104
  if self.transport not in _TRANSPORTS:
96
105
  msg = f"MCP server {self.name!r} transport must be one of {_TRANSPORTS}"
97
106
  raise ValueError(msg)
107
+ _validate_tool_timeout(self.name, self.tool_timeout_s)
108
+
109
+
110
+ def _validate_tool_timeout(name: str, timeout_s: float | None) -> None:
111
+ """Reject timeout values no provider can apply meaningfully."""
112
+ if timeout_s is None:
113
+ return
114
+ if isinstance(timeout_s, bool) or not math.isfinite(timeout_s) or timeout_s <= 0:
115
+ msg = f"MCP server {name!r} tool_timeout_s must be a positive finite number"
116
+ raise ValueError(msg)
98
117
 
99
118
 
100
119
  McpServer = StdioMcpServer | HttpMcpServer
@@ -0,0 +1,89 @@
1
+ """Declared provider capabilities.
2
+
3
+ Every optional behaviour is declared here (or on a protocol) so no caller
4
+ has to probe a provider object for attributes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from enum import Enum
11
+ from types import MappingProxyType
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Mapping
16
+
17
+
18
+ class McpMechanism(Enum):
19
+ """How a provider is told about MCP servers."""
20
+
21
+ NONE = "none"
22
+ CONFIG_FILE = "config_file"
23
+ CLI_FLAGS = "cli_flags"
24
+
25
+
26
+ class OutputSchemaStyle(Enum):
27
+ """How a provider accepts a JSON Schema for structured output."""
28
+
29
+ NONE = "none"
30
+ INLINE_JSON = "inline_json"
31
+ FILE_PATH = "file_path"
32
+
33
+
34
+ class SchemaDialect(Enum):
35
+ """Which JSON Schema subset a provider accepts.
36
+
37
+ ``STRICT`` is Codex's ``--output-schema`` subset: every object declares
38
+ its properties and forbids undeclared keys. ``OPEN`` also accepts a
39
+ schema-valued or ``true`` ``additionalProperties``.
40
+ """
41
+
42
+ STRICT = "strict"
43
+ OPEN = "open"
44
+
45
+
46
+ def _no_container_env() -> Mapping[str, str]:
47
+ """Empty read-only default: a frozen spec must not carry a mutable one.
48
+
49
+ A factory rather than a shared constant because Python 3.10 and 3.11
50
+ reject an unhashable dataclass default, and ``mappingproxy`` is one. See
51
+ ``agentshim.core.mcp._no_env`` for the same pattern.
52
+ """
53
+ return MappingProxyType({})
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class ProviderProfile:
58
+ """Everything a caller needs to know about a provider without running it.
59
+
60
+ The last four fields are additive (0.6.1+) and all default, so an
61
+ existing keyword-built ``ProviderProfile`` keeps working unchanged.
62
+ ``container_env`` is environment a CLI needs to run as root in a
63
+ container, beyond auth (e.g. Claude Code's ``IS_SANDBOX=1``).
64
+ ``state_root_env`` is the documented variable that relocates
65
+ ``state_dirs[0]``, or ``None`` when the CLI names none. ``auth_files`` are
66
+ the home-relative paths, each inside a ``state_dirs`` entry, whose
67
+ contents are the minimal set to copy so the CLI is logged in elsewhere.
68
+ ``mcp_config_file`` is the workspace-relative path a ``CONFIG_FILE``
69
+ provider writes its MCP config to for a turn, and ``None`` for
70
+ ``CLI_FLAGS``/``NONE`` providers.
71
+ """
72
+
73
+ name: str
74
+ display_name: str
75
+ binary: str
76
+ supports_resume: bool
77
+ supports_reasoning_effort: bool
78
+ mcp: McpMechanism
79
+ output_schema: OutputSchemaStyle
80
+ schema_dialect: SchemaDialect | None
81
+ state_dirs: tuple[str, ...]
82
+ darwin_state_dirs: tuple[str, ...]
83
+ auth_env_vars: tuple[str, ...]
84
+ skill_dirs: tuple[str, ...]
85
+ container_install: tuple[str, ...]
86
+ container_env: Mapping[str, str] = field(default_factory=_no_container_env)
87
+ state_root_env: str | None = None
88
+ auth_files: tuple[str, ...] = ()
89
+ mcp_config_file: str | None = None
@@ -11,14 +11,19 @@ from __future__ import annotations
11
11
  from typing import TYPE_CHECKING, Any, Protocol
12
12
 
13
13
  from .claude import ClaudeProvider
14
+ from .claude import resume_failure_lines as _claude_resume_failure
14
15
  from .claude import scripted_lines as _claude_scripted
15
16
  from .codex import CodexProvider
17
+ from .codex import resume_failure_lines as _codex_resume_failure
16
18
  from .codex import scripted_lines as _codex_scripted
17
19
  from .copilot import CopilotProvider
20
+ from .copilot import resume_failure_lines as _copilot_resume_failure
18
21
  from .copilot import scripted_lines as _copilot_scripted
19
22
  from .gemini import GeminiProvider
23
+ from .gemini import resume_failure_lines as _gemini_resume_failure
20
24
  from .gemini import scripted_lines as _gemini_scripted
21
25
  from .opencode import OpencodeProvider
26
+ from .opencode import resume_failure_lines as _opencode_resume_failure
22
27
  from .opencode import scripted_lines as _opencode_scripted
23
28
 
24
29
  if TYPE_CHECKING:
@@ -49,6 +54,21 @@ class ScriptedLines(Protocol):
49
54
  ...
50
55
 
51
56
 
57
+ class ResumeFailureLines(Protocol):
58
+ """Builds the stdout, stderr and exit code of a resumed turn gone bad."""
59
+
60
+ def __call__(
61
+ self, *, session_id: str | None = None
62
+ ) -> tuple[Sequence[str], Sequence[str], int]:
63
+ """Return ``(stdout, stderr, returncode)`` for a ``FakeRun``.
64
+
65
+ ``session_id`` is folded into the scripted message for realism only;
66
+ what a resumed turn's ``classify_exit`` actually reports comes from
67
+ the turn's own argv.
68
+ """
69
+ ...
70
+
71
+
52
72
  # To add a provider: implement providers/<name>/ (provider.py, parser.py,
53
73
  # events.py, scripted.py) and add one entry to each dict below.
54
74
  _FACTORIES: dict[str, Callable[[], Provider]] = {
@@ -67,6 +87,14 @@ _SCRIPTED: dict[str, ScriptedLines] = {
67
87
  "opencode": _opencode_scripted,
68
88
  }
69
89
 
90
+ _RESUME_FAILURES: dict[str, ResumeFailureLines] = {
91
+ "claude": _claude_resume_failure,
92
+ "codex": _codex_resume_failure,
93
+ "copilot": _copilot_resume_failure,
94
+ "gemini": _gemini_resume_failure,
95
+ "opencode": _opencode_resume_failure,
96
+ }
97
+
70
98
 
71
99
  def provider_names() -> list[str]:
72
100
  """Return the supported provider names, sorted."""
@@ -91,14 +119,27 @@ def get_scripted_lines(name: str) -> ScriptedLines:
91
119
  return scripted
92
120
 
93
121
 
122
+ def get_resume_failure_lines(name: str) -> ResumeFailureLines:
123
+ """Return the test-double resume-failure builder for *name*."""
124
+ resume_failure = _RESUME_FAILURES.get(name)
125
+ if resume_failure is None:
126
+ msg = (
127
+ f"no resume-failure stream for provider {name!r}; available: {sorted(_RESUME_FAILURES)}"
128
+ )
129
+ raise ValueError(msg)
130
+ return resume_failure
131
+
132
+
94
133
  __all__ = [
95
134
  "ClaudeProvider",
96
135
  "CodexProvider",
97
136
  "CopilotProvider",
98
137
  "GeminiProvider",
99
138
  "OpencodeProvider",
139
+ "ResumeFailureLines",
100
140
  "ScriptedLines",
101
141
  "get_provider",
142
+ "get_resume_failure_lines",
102
143
  "get_scripted_lines",
103
144
  "provider_names",
104
145
  ]
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from .parser import ClaudeStreamParser
6
6
  from .provider import PROFILE, ClaudeProvider, mcp_entry
7
7
  from .sandbox import SandboxConfig, build_settings, resolve_sandbox
8
- from .scripted import scripted_lines
8
+ from .scripted import resume_failure_lines, scripted_lines
9
9
 
10
10
  __all__ = [
11
11
  "PROFILE",
@@ -15,5 +15,6 @@ __all__ = [
15
15
  "build_settings",
16
16
  "mcp_entry",
17
17
  "resolve_sandbox",
18
+ "resume_failure_lines",
18
19
  "scripted_lines",
19
20
  ]
@@ -50,6 +50,18 @@ PROFILE = ProviderProfile(
50
50
  # The installer drops the binary in /root/.local/bin.
51
51
  "ln -sf /root/.local/bin/claude /usr/local/bin/claude",
52
52
  ),
53
+ # Claude Code refuses --dangerously-skip-permissions as root unless this
54
+ # is set.
55
+ container_env={"IS_SANDBOX": "1"},
56
+ # Documented Claude Code variable that relocates ~/.claude.
57
+ state_root_env="CLAUDE_CONFIG_DIR",
58
+ auth_files=(
59
+ ".claude/.credentials.json",
60
+ ".claude/settings.json",
61
+ ".claude/settings.local.json",
62
+ ".claude.json",
63
+ ),
64
+ mcp_config_file=MCP_CONFIG_FILENAME,
53
65
  )
54
66
 
55
67
 
@@ -150,6 +162,9 @@ def _resumed_session_id(argv: Sequence[str]) -> str | None:
150
162
 
151
163
  def mcp_entry(server: McpServer) -> dict[str, Any]:
152
164
  """Render one server the way ``.mcp.json`` describes it."""
165
+ if server.tool_timeout_s is not None:
166
+ msg = "claude cannot configure a per-server MCP tool timeout"
167
+ raise ProviderCapabilityError(msg)
153
168
  if isinstance(server, HttpMcpServer):
154
169
  # Claude validates the config strictly and needs an explicit type; it
155
170
  # names the two remote transports exactly as the spec does.
@@ -81,6 +81,27 @@ def scripted_lines(
81
81
  return lines
82
82
 
83
83
 
84
+ def resume_failure_lines(*, session_id: str | None = None) -> tuple[list[str], list[str], int]:
85
+ """Build the stdout, stderr and exit code of a resumed turn Claude cannot continue.
86
+
87
+ ``ClaudeProvider.classify_exit`` treats any nonzero exit of a resumed
88
+ turn as ``SessionResumeError``: ``claude --resume`` gives no
89
+ distinguishable exit code for a missing transcript, so a bare failure on
90
+ stderr is enough to trigger it. ``session_id`` is folded into the message
91
+ for realism only; the id ``SessionResumeError`` actually reports comes
92
+ from the resumed turn's own argv, not from anything scripted here.
93
+
94
+ Args:
95
+ session_id: Conversation id to name in the scripted stderr message.
96
+
97
+ Returns:
98
+ ``(stdout, stderr, returncode)`` for a ``FakeRun``.
99
+ """
100
+ detail = f" {session_id}" if session_id else ""
101
+ stderr = [f"Error: no conversation found to resume{detail}\n"]
102
+ return [], stderr, 1
103
+
104
+
84
105
  def _usage_payload(usage: TokenUsage | None) -> dict[str, int]:
85
106
  if usage is None:
86
107
  return {
@@ -3,12 +3,14 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from .parser import CodexStreamParser
6
- from .provider import PROFILE, CodexProvider
7
- from .scripted import scripted_lines
6
+ from .provider import PROFILE, CodexProvider, parse_mcp_servers
7
+ from .scripted import resume_failure_lines, scripted_lines
8
8
 
9
9
  __all__ = [
10
10
  "PROFILE",
11
11
  "CodexProvider",
12
12
  "CodexStreamParser",
13
+ "parse_mcp_servers",
14
+ "resume_failure_lines",
13
15
  "scripted_lines",
14
16
  ]
@@ -2,7 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import TYPE_CHECKING
5
+ import re
6
+ from typing import TYPE_CHECKING, Any
6
7
 
7
8
  from agentshim.core.errors import ProviderCapabilityError, SessionResumeError
8
9
  from agentshim.core.mcp import FlagsInstallation, HttpMcpServer, NoopInstallation
@@ -71,6 +72,13 @@ PROFILE = ProviderProfile(
71
72
  auth_env_vars=("OPENAI_API_KEY", "OPENAI_BASE_URL"),
72
73
  skill_dirs=(".agents/skills",),
73
74
  container_install=(_NODE_INSTALL, _CODEX_INSTALL),
75
+ # Documented Codex CLI variable that relocates ~/.codex (``codex --help``:
76
+ # "Layer $CODEX_HOME/<name>.config.toml on top of the base user config";
77
+ # "auth still uses CODEX_HOME").
78
+ state_root_env="CODEX_HOME",
79
+ auth_files=(".codex/auth.json", ".codex/config.toml"),
80
+ # CLI_FLAGS: Codex takes MCP servers as --config overrides, never a file.
81
+ mcp_config_file=None,
74
82
  )
75
83
 
76
84
 
@@ -178,7 +186,8 @@ def _server_flags(server: McpServer) -> list[str]:
178
186
  raise ProviderCapabilityError(_HTTP_HEADERS_UNSUPPORTED)
179
187
  # A ``--config`` override carries the address and nothing else, so
180
188
  # Codex works the transport out from the endpoint itself.
181
- return ["--config", f"{prefix}.url={_toml_str(server.url)}"]
189
+ flags = ["--config", f"{prefix}.url={_toml_str(server.url)}"]
190
+ return flags + _tool_timeout_flags(prefix, server.tool_timeout_s)
182
191
  flags = [
183
192
  "--config",
184
193
  f"{prefix}.command={_toml_str(server.command)}",
@@ -187,7 +196,14 @@ def _server_flags(server: McpServer) -> list[str]:
187
196
  ]
188
197
  for env_key, env_value in server.env.items():
189
198
  flags += ["--config", f"{prefix}.env.{env_key}={_toml_str(env_value)}"]
190
- return flags
199
+ return flags + _tool_timeout_flags(prefix, server.tool_timeout_s)
200
+
201
+
202
+ def _tool_timeout_flags(prefix: str, timeout_s: float | None) -> list[str]:
203
+ """Render Codex's per-server tool timeout as an invocation override."""
204
+ if timeout_s is None:
205
+ return []
206
+ return ["--config", f"{prefix}.tool_timeout_sec={timeout_s}"]
191
207
 
192
208
 
193
209
  def _toml_str(value: str) -> str:
@@ -199,3 +215,95 @@ def _toml_str(value: str) -> str:
199
215
  def _toml_array(values: Sequence[str]) -> str:
200
216
  """Render a sequence of strings as a TOML inline array."""
201
217
  return "[" + ",".join(_toml_str(value) for value in values) + "]"
218
+
219
+
220
+ _CONFIG_OVERRIDE_RE = re.compile(r"^mcp_servers\.([^.=]+)\.(.+)$")
221
+ _TOML_STRING_RE = re.compile(r'"((?:[^"\\]|\\.)*)"')
222
+
223
+
224
+ def parse_mcp_servers(argv: Sequence[str]) -> dict[str, dict[str, Any]]:
225
+ """Recover the MCP servers rendered into *argv* by ``_server_flags``.
226
+
227
+ The inverse of that renderer, kept next to it so the two cannot drift:
228
+ walks the ``--config mcp_servers.<key>.<field>=<value>`` overrides this
229
+ provider emits and rebuilds one canonical entry per server. A stdio
230
+ server comes back as ``command``, ``args`` and ``env``; an HTTP one as
231
+ ``url`` and ``transport`` (always ``"http"``, since a ``--config``
232
+ override carries only the address and this provider cannot express any
233
+ other transport).
234
+
235
+ Args:
236
+ argv: The argv a turn actually ran, as recorded on a ``CommandRequest``.
237
+
238
+ Returns:
239
+ One canonical entry per server, keyed by the dotted-path name
240
+ ``_server_flags`` gave it (``-`` already mangled to ``_``).
241
+ """
242
+ servers: dict[str, dict[str, Any]] = {}
243
+ for key, field, raw_value in _mcp_config_overrides(argv):
244
+ _apply_mcp_override(servers.setdefault(key, {}), field, raw_value)
245
+ for entry in servers.values():
246
+ if "url" not in entry:
247
+ entry.setdefault("args", [])
248
+ entry.setdefault("env", {})
249
+ return servers
250
+
251
+
252
+ def _mcp_config_overrides(argv: Sequence[str]) -> list[tuple[str, str, str]]:
253
+ """Pull every ``mcp_servers.<key>.<field>=<value>`` override out of *argv*."""
254
+ args = list(argv)
255
+ overrides: list[tuple[str, str, str]] = []
256
+ for index, arg in enumerate(args):
257
+ if arg != "--config" or index + 1 >= len(args):
258
+ continue
259
+ path, sep, raw_value = args[index + 1].partition("=")
260
+ if not sep:
261
+ continue
262
+ match = _CONFIG_OVERRIDE_RE.match(path)
263
+ if match is not None:
264
+ key, field = match.groups()
265
+ overrides.append((key, field, raw_value))
266
+ return overrides
267
+
268
+
269
+ def _apply_mcp_override(entry: dict[str, Any], field: str, raw_value: str) -> None:
270
+ """Fold one dotted-path override into the entry being rebuilt for it."""
271
+ if field == "command":
272
+ entry["command"] = _parse_toml_str(raw_value)
273
+ elif field == "args":
274
+ entry["args"] = _parse_toml_array(raw_value)
275
+ elif field == "url":
276
+ entry["url"] = _parse_toml_str(raw_value)
277
+ entry["transport"] = "http"
278
+ elif field == "tool_timeout_sec":
279
+ entry["tool_timeout_s"] = float(raw_value)
280
+ elif field.startswith("env."):
281
+ entry.setdefault("env", {})[field.removeprefix("env.")] = _parse_toml_str(raw_value)
282
+
283
+
284
+ def _parse_toml_str(literal: str) -> str:
285
+ """Reverse ``_toml_str``: unescape one TOML basic string literal."""
286
+ match = _TOML_STRING_RE.fullmatch(literal)
287
+ if match is None:
288
+ return literal
289
+ return _unescape_toml(match.group(1))
290
+
291
+
292
+ def _parse_toml_array(literal: str) -> list[str]:
293
+ """Reverse ``_toml_array``: unescape every string in a TOML inline array."""
294
+ return [_unescape_toml(inner) for inner in _TOML_STRING_RE.findall(literal)]
295
+
296
+
297
+ def _unescape_toml(escaped: str) -> str:
298
+ """Undo the backslash-escaping ``_toml_str`` applies to a string body."""
299
+ result: list[str] = []
300
+ index = 0
301
+ while index < len(escaped):
302
+ char = escaped[index]
303
+ if char == "\\" and index + 1 < len(escaped):
304
+ result.append(escaped[index + 1])
305
+ index += 2
306
+ else:
307
+ result.append(char)
308
+ index += 1
309
+ return "".join(result)
@@ -85,6 +85,25 @@ def scripted_lines(
85
85
  return lines
86
86
 
87
87
 
88
+ def resume_failure_lines(*, session_id: str | None = None) -> tuple[list[str], list[str], int]:
89
+ """Build the stdout, stderr and exit code of a resumed turn with no rollout.
90
+
91
+ ``CodexProvider.classify_exit`` is the one provider that names its
92
+ failure explicitly: it matches "thread/resume failed" and "no rollout
93
+ found" on stderr, so unlike the other providers a plain nonzero exit is
94
+ not enough here; the message has to say so.
95
+
96
+ Args:
97
+ session_id: Thread id to name in the scripted stderr message.
98
+
99
+ Returns:
100
+ ``(stdout, stderr, returncode)`` for a ``FakeRun``.
101
+ """
102
+ thread = session_id or "unknown-thread"
103
+ stderr = [f"thread/resume failed: no rollout found for thread {thread}\n"]
104
+ return [], stderr, 1
105
+
106
+
88
107
  def _usage_payload(usage: TokenUsage | None) -> dict[str, int]:
89
108
  if usage is None:
90
109
  return {"input_tokens": 0, "cached_input_tokens": 0, "output_tokens": 0}
@@ -3,13 +3,15 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from .parser import CopilotStreamParser
6
- from .provider import PROFILE, CopilotProvider, mcp_entry
7
- from .scripted import scripted_lines
6
+ from .provider import PROFILE, CopilotProvider, mcp_entry, parse_mcp_servers
7
+ from .scripted import resume_failure_lines, scripted_lines
8
8
 
9
9
  __all__ = [
10
10
  "PROFILE",
11
11
  "CopilotProvider",
12
12
  "CopilotStreamParser",
13
13
  "mcp_entry",
14
+ "parse_mcp_servers",
15
+ "resume_failure_lines",
14
16
  "scripted_lines",
15
17
  ]