agentshim 0.16.0__tar.gz → 0.17.0__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 (113) hide show
  1. {agentshim-0.16.0 → agentshim-0.17.0}/CHANGELOG.md +28 -0
  2. {agentshim-0.16.0 → agentshim-0.17.0}/PKG-INFO +1 -1
  3. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/__init__.py +11 -1
  4. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/__init__.py +10 -0
  5. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/auth.py +270 -11
  6. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/profile.py +10 -4
  7. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/claude/provider.py +22 -3
  8. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/gemini/provider.py +18 -1
  9. {agentshim-0.16.0 → agentshim-0.17.0}/pyproject.toml +1 -1
  10. {agentshim-0.16.0 → agentshim-0.17.0}/.gitignore +0 -0
  11. {agentshim-0.16.0 → agentshim-0.17.0}/LICENSE +0 -0
  12. {agentshim-0.16.0 → agentshim-0.17.0}/README.md +0 -0
  13. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/agent.py +0 -0
  14. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/_files.py +0 -0
  15. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/checkpoints.py +0 -0
  16. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/clock.py +0 -0
  17. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/conversation.py +0 -0
  18. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/env.py +0 -0
  19. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/errors.py +0 -0
  20. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/events.py +0 -0
  21. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/home.py +0 -0
  22. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/ids.py +0 -0
  23. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/mcp.py +0 -0
  24. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/permissions.py +0 -0
  25. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/pricing.py +0 -0
  26. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/provider.py +0 -0
  27. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/schema.py +0 -0
  28. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/session_policy.py +0 -0
  29. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/skills.py +0 -0
  30. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/status.py +0 -0
  31. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/stream.py +0 -0
  32. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/turn.py +0 -0
  33. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/core/usage.py +0 -0
  34. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/execution/__init__.py +0 -0
  35. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/execution/confinement.py +0 -0
  36. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/execution/docker.py +0 -0
  37. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/execution/executor.py +0 -0
  38. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/execution/host.py +0 -0
  39. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/execution/process.py +0 -0
  40. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/execution/transform.py +0 -0
  41. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/oneshot.py +0 -0
  42. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/probe.py +0 -0
  43. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/__init__.py +0 -0
  44. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/claude/__init__.py +0 -0
  45. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/claude/events.py +0 -0
  46. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/claude/failures.py +0 -0
  47. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/claude/hooks/__init__.py +0 -0
  48. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
  49. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/claude/parser.py +0 -0
  50. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/claude/probe.py +0 -0
  51. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/claude/rate_limits.py +0 -0
  52. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/claude/sandbox.py +0 -0
  53. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/claude/scripted.py +0 -0
  54. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/claude/stream_argv.py +0 -0
  55. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/claude/stream_transport.py +0 -0
  56. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/claude/user_hooks.py +0 -0
  57. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/__init__.py +0 -0
  58. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/_toml.py +0 -0
  59. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/app_server/__init__.py +0 -0
  60. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/app_server/_wire.py +0 -0
  61. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/app_server/approvals.py +0 -0
  62. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/app_server/channel.py +0 -0
  63. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/app_server/errors.py +0 -0
  64. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/app_server/items.py +0 -0
  65. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/app_server/mcp.py +0 -0
  66. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/app_server/permissions.py +0 -0
  67. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/app_server/profile.py +0 -0
  68. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/app_server/protocol.py +0 -0
  69. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/app_server/rate_limits.py +0 -0
  70. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/app_server/transport.py +0 -0
  71. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/app_server/usage.py +0 -0
  72. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/events.py +0 -0
  73. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/failures.py +0 -0
  74. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/mcp_scope.py +0 -0
  75. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/parser.py +0 -0
  76. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/probe.py +0 -0
  77. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/provider.py +0 -0
  78. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/rules.py +0 -0
  79. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/sandbox.py +0 -0
  80. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/scripted.py +0 -0
  81. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/codex/skills.py +0 -0
  82. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/copilot/__init__.py +0 -0
  83. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/copilot/events.py +0 -0
  84. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/copilot/parser.py +0 -0
  85. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/copilot/probe.py +0 -0
  86. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/copilot/provider.py +0 -0
  87. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/copilot/scripted.py +0 -0
  88. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/gemini/__init__.py +0 -0
  89. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/gemini/events.py +0 -0
  90. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/gemini/parser.py +0 -0
  91. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/gemini/probe.py +0 -0
  92. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/gemini/scripted.py +0 -0
  93. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/opencode/__init__.py +0 -0
  94. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/opencode/events.py +0 -0
  95. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/opencode/parser.py +0 -0
  96. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/opencode/probe.py +0 -0
  97. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/opencode/provider.py +0 -0
  98. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/providers/opencode/scripted.py +0 -0
  99. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/py.typed +0 -0
  100. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/runtime.py +0 -0
  101. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/session.py +0 -0
  102. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/testing/__init__.py +0 -0
  103. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/testing/claude_stream.py +0 -0
  104. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/testing/clock.py +0 -0
  105. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/testing/codex_app_server.py +0 -0
  106. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/testing/confinement.py +0 -0
  107. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/testing/contracts.py +0 -0
  108. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/testing/conversation.py +0 -0
  109. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/testing/probe.py +0 -0
  110. {agentshim-0.16.0 → agentshim-0.17.0}/agentshim/testing/process.py +0 -0
  111. {agentshim-0.16.0 → agentshim-0.17.0}/tests/fixtures/claude_stream/README.md +0 -0
  112. {agentshim-0.16.0 → agentshim-0.17.0}/tests/fixtures/codex/README.md +0 -0
  113. {agentshim-0.16.0 → agentshim-0.17.0}/tests/fixtures/codex_app_server/README.md +0 -0
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.0 (2026-10-15)
4
+
5
+ ### Added
6
+
7
+ - `host_auth_plan(profile, env=, home=)` returns the `AuthPlan` that reproduces how the
8
+ CLI logs in by itself on a host: the first present credential source of any kind, plus
9
+ the host's endpoint variables exactly as set, so a confined copy given only the plan
10
+ sends the same credential to the same place. A host base URL is reproduced, not
11
+ validated, and a credential goes to the host's endpoint whatever its kind (Claude Code
12
+ sends `CLAUDE_CODE_OAUTH_TOKEN` to a custom `ANTHROPIC_BASE_URL`). It raises
13
+ `HostAuthUnplannableError`, whose `reason` (`HostAuthGap`) is `UNCLASSIFIED`,
14
+ `NO_CREDENTIAL`, `EXTERNAL_CREDENTIAL` or `SETTINGS_ENV`, when no plan can reproduce
15
+ the host's login.
16
+ - `ExternalCredential`, a `CredentialSource` for a way the CLI logs in that no plan can
17
+ hand over, detected by variables, paths or settings keys. Claude declares a cloud
18
+ provider (`CLAUDE_CODE_USE_BEDROCK`, `_VERTEX`, `_FOUNDRY`), `apiKeyHelper`, and
19
+ Anthropic profiles at their documented places in its precedence; Gemini declares an
20
+ auth type selected in settings or by variable, and `.gemini/.env`.
21
+ - `ProviderProfile.settings` (`SettingsSpec`): the settings files a CLI reads at start
22
+ that can change how it logs in, and the key of the block it applies over its
23
+ environment (Claude's `env`).
24
+
25
+ ### Changed
26
+
27
+ - `CredentialSource` is now `EnvCredential | FileCredential | ExternalCredential`;
28
+ code that matched on two variants must handle the third. `plan_auth` never chooses an
29
+ `ExternalCredential` and names a present one in `AuthPlan.withheld`.
30
+
3
31
  ## 0.16.0 (2026-10-14)
4
32
 
5
33
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentshim
3
- Version: 0.16.0
3
+ Version: 0.17.0
4
4
  Summary: Provider-agnostic coding-agent CLI shims
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -40,9 +40,12 @@ from .core import (
40
40
  EndpointSpec,
41
41
  EnvCredential,
42
42
  EventHandlerBase,
43
+ ExternalCredential,
43
44
  FailureKind,
44
45
  FileCredential,
45
46
  FlagsInstallation,
47
+ HostAuthGap,
48
+ HostAuthUnplannableError,
46
49
  HttpMcpServer,
47
50
  IdAllocator,
48
51
  InMemoryCheckpointStore,
@@ -88,6 +91,7 @@ from .core import (
88
91
  SessionStarted,
89
92
  SessionState,
90
93
  SessionStateError,
94
+ SettingsSpec,
91
95
  SkillInvoked,
92
96
  SkillScope,
93
97
  SkillsDiscovered,
@@ -123,6 +127,7 @@ from .core import (
123
127
  cost_usd,
124
128
  default_pricing,
125
129
  dialect_problems,
130
+ host_auth_plan,
126
131
  install_config_file,
127
132
  interactive_env,
128
133
  materialize,
@@ -166,7 +171,7 @@ from .providers.opencode import OpencodeProvider
166
171
  from .runtime import Agent
167
172
  from .session import Session, Turn, TurnTicket, map_mcp_servers
168
173
 
169
- __version__ = "0.16.0"
174
+ __version__ = "0.17.0"
170
175
 
171
176
  __all__ = [
172
177
  "Agent",
@@ -219,10 +224,13 @@ __all__ = [
219
224
  "EndpointSpec",
220
225
  "EnvCredential",
221
226
  "EventHandlerBase",
227
+ "ExternalCredential",
222
228
  "FailureKind",
223
229
  "FileCredential",
224
230
  "FlagsInstallation",
225
231
  "GeminiProvider",
232
+ "HostAuthGap",
233
+ "HostAuthUnplannableError",
226
234
  "HostCommandExecutor",
227
235
  "HostProcess",
228
236
  "HttpMcpServer",
@@ -278,6 +286,7 @@ __all__ = [
278
286
  "SessionStarted",
279
287
  "SessionState",
280
288
  "SessionStateError",
289
+ "SettingsSpec",
281
290
  "SkillInvoked",
282
291
  "SkillScope",
283
292
  "SkillSignal",
@@ -322,6 +331,7 @@ __all__ = [
322
331
  "default_pricing",
323
332
  "dialect_problems",
324
333
  "get_provider",
334
+ "host_auth_plan",
325
335
  "install_config_file",
326
336
  "interactive_env",
327
337
  "map_mcp_servers",
@@ -15,8 +15,13 @@ from .auth import (
15
15
  CredentialSource,
16
16
  EndpointSpec,
17
17
  EnvCredential,
18
+ ExternalCredential,
18
19
  FileCredential,
20
+ HostAuthGap,
21
+ HostAuthUnplannableError,
22
+ SettingsSpec,
19
23
  StagedCredentialFile,
24
+ host_auth_plan,
20
25
  plan_auth,
21
26
  )
22
27
  from .checkpoints import Checkpoint, CheckpointStore, InMemoryCheckpointStore
@@ -153,9 +158,12 @@ __all__ = [
153
158
  "EndpointSpec",
154
159
  "EnvCredential",
155
160
  "EventHandlerBase",
161
+ "ExternalCredential",
156
162
  "FailureKind",
157
163
  "FileCredential",
158
164
  "FlagsInstallation",
165
+ "HostAuthGap",
166
+ "HostAuthUnplannableError",
159
167
  "HttpMcpServer",
160
168
  "IdAllocator",
161
169
  "InMemoryCheckpointStore",
@@ -201,6 +209,7 @@ __all__ = [
201
209
  "SessionStarted",
202
210
  "SessionState",
203
211
  "SessionStateError",
212
+ "SettingsSpec",
204
213
  "SkillInvoked",
205
214
  "SkillScope",
206
215
  "SkillSignal",
@@ -236,6 +245,7 @@ __all__ = [
236
245
  "cost_usd",
237
246
  "default_pricing",
238
247
  "dialect_problems",
248
+ "host_auth_plan",
239
249
  "install_config_file",
240
250
  "interactive_env",
241
251
  "materialize",
@@ -16,6 +16,12 @@ exact environment variables and files to deliver. The caller delivers the
16
16
  plan and nothing more of the provider's ``auth_env_vars`` or ``auth_files``;
17
17
  the readiness probe then reports which kind of credential the CLI says it is
18
18
  using (``ProviderStatus.credential``), so the caller can confirm it.
19
+
20
+ A caller with no policy of its own, that wants a confined copy of the CLI to
21
+ log in exactly as the CLI would on the host, asks ``host_auth_plan`` instead:
22
+ it applies the CLI's own precedence with every kind allowed and the host's
23
+ endpoint as it is, and refuses (``HostAuthUnplannableError``) when the host
24
+ CLI would log in some way a plan cannot reproduce.
19
25
  """
20
26
 
21
27
  from __future__ import annotations
@@ -85,7 +91,49 @@ class FileCredential:
85
91
  return self.classify(contents)
86
92
 
87
93
 
88
- CredentialSource = EnvCredential | FileCredential
94
+ @dataclass(frozen=True)
95
+ class ExternalCredential:
96
+ """A way the CLI logs in that no plan can hand to another copy of it.
97
+
98
+ A cloud provider selected by a variable (whose own credentials live in
99
+ other tools' state), a helper command named in a settings file, a
100
+ credential store outside the CLI's state root. It is declared at its place
101
+ in the precedence order so ``host_auth_plan`` knows when the host CLI would
102
+ use it; ``plan_auth`` never chooses it. It is present when any variable in
103
+ ``env`` is set, any home-relative path in ``paths`` exists, or any of the
104
+ profile's ``settings`` files has one of ``settings_keys`` at its top level.
105
+ """
106
+
107
+ label: str
108
+ env: tuple[str, ...] = ()
109
+ paths: tuple[str, ...] = ()
110
+ settings_keys: tuple[str, ...] = ()
111
+
112
+ def __post_init__(self) -> None:
113
+ """Require some way to detect the credential."""
114
+ if not (self.env or self.paths or self.settings_keys):
115
+ msg = f"ExternalCredential {self.label!r} needs an env, paths or settings_keys entry"
116
+ raise ValueError(msg)
117
+
118
+
119
+ CredentialSource = EnvCredential | FileCredential | ExternalCredential
120
+ #: The sources a plan can hand over.
121
+ _DeliverableSource = EnvCredential | FileCredential
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class SettingsSpec:
126
+ """The JSON settings files a CLI reads from its home when it starts.
127
+
128
+ ``files`` are home-relative paths. ``env_key`` names a top-level object
129
+ whose string entries the CLI applies over its own environment (Claude
130
+ Code's ``env``), or ``None`` when the CLI has no such block. Settings can
131
+ change which credential or endpoint the CLI uses, so ``host_auth_plan``
132
+ reads them; neither plan function delivers them.
133
+ """
134
+
135
+ files: tuple[str, ...]
136
+ env_key: str | None = None
89
137
 
90
138
 
91
139
  @dataclass(frozen=True)
@@ -170,6 +218,37 @@ class AuthUnavailableError(AgentShimError):
170
218
  """
171
219
 
172
220
 
221
+ class HostAuthGap(str, Enum):
222
+ """Why ``host_auth_plan`` cannot reproduce how the host CLI logs in.
223
+
224
+ ``UNCLASSIFIED``: the profile declares no credential sources. ``NO_CREDENTIAL``:
225
+ none of the declared sources is present, so the CLI either cannot log in
226
+ or does it some way agentshim does not know. ``EXTERNAL_CREDENTIAL``: the
227
+ CLI would use an ``ExternalCredential``. ``SETTINGS_ENV``: a settings file
228
+ sets a credential or endpoint variable of its own, so the environment the
229
+ CLI logs in with is not the caller's.
230
+ """
231
+
232
+ UNCLASSIFIED = "unclassified"
233
+ NO_CREDENTIAL = "no_credential"
234
+ EXTERNAL_CREDENTIAL = "external_credential"
235
+ SETTINGS_ENV = "settings_env"
236
+
237
+
238
+ class HostAuthUnplannableError(AgentShimError):
239
+ """The host CLI's login cannot be reproduced by a plan; ``reason`` says why.
240
+
241
+ The message names the variables or files involved, never a value. A caller
242
+ that still wants the CLI logged in elsewhere has only the host's whole auth
243
+ state (``auth_env_vars`` and ``auth_files``) to hand over.
244
+ """
245
+
246
+ def __init__(self, reason: HostAuthGap, message: str) -> None:
247
+ """Record *reason* beside the message."""
248
+ super().__init__(message)
249
+ self.reason = reason
250
+
251
+
173
252
  def plan_auth(
174
253
  profile: ProviderProfile,
175
254
  policy: AuthPolicy,
@@ -205,10 +284,6 @@ def plan_auth(
205
284
  {name: env[name] for name in profile.endpoint.companion_env if _set(env, name)}
206
285
  )
207
286
 
208
- delivered = set(delivered_env) | {staged.home_path for staged in files}
209
- on_host = {name for name in _host_auth_names(profile) if _set(env, name)}
210
- on_host |= {path for path in profile.auth_files if (home / path).is_file()}
211
- on_host |= set(present)
212
287
  return AuthPlan(
213
288
  provider=profile.name,
214
289
  credential=kind,
@@ -216,7 +291,61 @@ def plan_auth(
216
291
  endpoint=policy.endpoint,
217
292
  env=MappingProxyType(delivered_env),
218
293
  files=files,
219
- withheld=tuple(sorted(on_host - delivered)),
294
+ withheld=_withheld(profile, env, home, present, _delivered(delivered_env, files)),
295
+ )
296
+
297
+
298
+ def host_auth_plan(profile: ProviderProfile, *, env: Mapping[str, str], home: Path) -> AuthPlan:
299
+ """Say how *profile*'s CLI logs in by itself on a host, as a plan a confined copy can use.
300
+
301
+ The CLI's own choice: the first present source in ``credential_sources``,
302
+ of any kind, at whatever endpoint *env* points it to. The plan delivers
303
+ that credential, and the host's endpoint variables (``EndpointSpec``
304
+ ``base_url_env`` and ``companion_env``) exactly as they are set, so a
305
+ confined copy given only the plan sends the same credential to the same
306
+ place. ``AuthPlan.endpoint`` is the host's base URL, or ``None`` for the
307
+ vendor's endpoint.
308
+
309
+ The host's base URL is reproduced, not validated: a value the CLI cannot
310
+ use fails the copy the same way it fails the host CLI (Claude Code fails
311
+ every request with "Invalid URL" and never falls back to its vendor). The
312
+ credential's kind is not checked against ``EndpointSpec.credentials``
313
+ either: what the CLI does with a credential and a custom endpoint is the
314
+ CLI's behaviour, and the copy reproduces it.
315
+
316
+ Raises:
317
+ HostAuthUnplannableError: the profile declares no credential sources,
318
+ none is present, the CLI would log in with an
319
+ ``ExternalCredential``, or a settings file sets a credential or
320
+ endpoint variable; ``reason`` is the matching ``HostAuthGap``.
321
+ """
322
+ if not profile.credential_sources:
323
+ msg = f"{profile.name} declares no credential sources agentshim can classify"
324
+ raise HostAuthUnplannableError(HostAuthGap.UNCLASSIFIED, msg)
325
+ settings = _read_settings(profile, home)
326
+ _refuse_settings_env(profile, settings)
327
+ chosen, kind = _host_choice(profile, env, home, settings)
328
+ delivered_env: dict[str, str] = {}
329
+ files: tuple[StagedCredentialFile, ...] = ()
330
+ if isinstance(chosen, EnvCredential):
331
+ delivered_env[chosen.name] = env[chosen.name]
332
+ else:
333
+ refreshable = kind is CredentialKind.SUBSCRIPTION
334
+ files = (StagedCredentialFile(home / chosen.path, chosen.path, refreshable),)
335
+ endpoint: str | None = None
336
+ if profile.endpoint is not None:
337
+ names = (profile.endpoint.base_url_env, *profile.endpoint.companion_env)
338
+ delivered_env.update({name: env[name] for name in names if _set(env, name)})
339
+ endpoint = delivered_env.get(profile.endpoint.base_url_env)
340
+ present = _present_names(profile, env, home, settings)
341
+ return AuthPlan(
342
+ provider=profile.name,
343
+ credential=kind,
344
+ source=_name(chosen),
345
+ endpoint=endpoint,
346
+ env=MappingProxyType(delivered_env),
347
+ files=files,
348
+ withheld=_withheld(profile, env, home, present, _delivered(delivered_env, files)),
220
349
  )
221
350
 
222
351
 
@@ -270,11 +399,14 @@ def _choose(
270
399
  allowed: frozenset[CredentialKind],
271
400
  env: Mapping[str, str],
272
401
  home: Path,
273
- ) -> tuple[CredentialSource, CredentialKind, list[str]]:
402
+ ) -> tuple[_DeliverableSource, CredentialKind, list[str]]:
274
403
  """The first present allowed source, its kind, and every present source's name."""
275
- chosen: tuple[CredentialSource, CredentialKind] | None = None
404
+ chosen: tuple[_DeliverableSource, CredentialKind] | None = None
276
405
  present: list[str] = []
277
406
  for source in profile.credential_sources:
407
+ if isinstance(source, ExternalCredential):
408
+ present += _external_found(profile, source, env, home)
409
+ continue
278
410
  kind = _present_kind(source, env, home)
279
411
  if kind is None:
280
412
  continue
@@ -286,8 +418,134 @@ def _choose(
286
418
  return chosen[0], chosen[1], present
287
419
 
288
420
 
421
+ def _host_choice(
422
+ profile: ProviderProfile,
423
+ env: Mapping[str, str],
424
+ home: Path,
425
+ settings: Mapping[str, dict[str, Any]],
426
+ ) -> tuple[_DeliverableSource, CredentialKind]:
427
+ """The source the CLI itself would log in with: the first present one, of any kind."""
428
+ for source in profile.credential_sources:
429
+ if isinstance(source, ExternalCredential):
430
+ found = _external_found(profile, source, env, home, settings)
431
+ if found:
432
+ msg = (
433
+ f"{profile.name} would log in with {source.label} "
434
+ f"({', '.join(found)}), which no plan can hand to another copy of it"
435
+ )
436
+ raise HostAuthUnplannableError(HostAuthGap.EXTERNAL_CREDENTIAL, msg)
437
+ continue
438
+ kind = _present_kind(source, env, home)
439
+ if kind is not None:
440
+ return source, kind
441
+ wanted = ", ".join(
442
+ _name(source)
443
+ for source in profile.credential_sources
444
+ if not isinstance(source, ExternalCredential)
445
+ )
446
+ msg = f"no credential {profile.name} logs in with is present: none of {wanted}"
447
+ raise HostAuthUnplannableError(HostAuthGap.NO_CREDENTIAL, msg)
448
+
449
+
450
+ def _read_settings(profile: ProviderProfile, home: Path) -> dict[str, dict[str, Any]]:
451
+ """Each of *profile*'s settings files that holds a JSON object, by path."""
452
+ out: dict[str, dict[str, Any]] = {}
453
+ for path in profile.settings.files if profile.settings is not None else ():
454
+ try:
455
+ data: Any = json.loads((home / path).read_bytes())
456
+ except (OSError, ValueError):
457
+ continue
458
+ if isinstance(data, dict):
459
+ out[path] = cast("dict[str, Any]", data)
460
+ return out
461
+
462
+
463
+ def _refuse_settings_env(profile: ProviderProfile, settings: Mapping[str, dict[str, Any]]) -> None:
464
+ """Raise when a settings file's env block sets a variable the CLI logs in with."""
465
+ key = profile.settings.env_key if profile.settings is not None else None
466
+ if key is None:
467
+ return
468
+ names = _host_auth_names(profile) | _credential_env_names(profile)
469
+ for path, data in settings.items():
470
+ block: Any = data.get(key)
471
+ if not isinstance(block, dict):
472
+ continue
473
+ overridden = sorted(names & set(cast("dict[str, Any]", block)))
474
+ if overridden:
475
+ msg = (
476
+ f"{path} sets {', '.join(overridden)} in its {key!r} block, so "
477
+ f"{profile.name} does not log in with the caller's environment"
478
+ )
479
+ raise HostAuthUnplannableError(HostAuthGap.SETTINGS_ENV, msg)
480
+
481
+
482
+ def _external_found(
483
+ profile: ProviderProfile,
484
+ source: ExternalCredential,
485
+ env: Mapping[str, str],
486
+ home: Path,
487
+ settings: Mapping[str, dict[str, Any]] | None = None,
488
+ ) -> list[str]:
489
+ """What of *source* is on the host: set variables, existing paths, settings keys."""
490
+ found = [name for name in source.env if _set(env, name)]
491
+ found += [path for path in source.paths if (home / path).exists()]
492
+ if source.settings_keys:
493
+ loaded = _read_settings(profile, home) if settings is None else settings
494
+ found += [
495
+ f"{key} in {path}"
496
+ for path, data in loaded.items()
497
+ for key in source.settings_keys
498
+ if key in data
499
+ ]
500
+ return found
501
+
502
+
503
+ def _present_names(
504
+ profile: ProviderProfile,
505
+ env: Mapping[str, str],
506
+ home: Path,
507
+ settings: Mapping[str, dict[str, Any]],
508
+ ) -> list[str]:
509
+ """The names of every declared source present on the host."""
510
+ names: list[str] = []
511
+ for source in profile.credential_sources:
512
+ if isinstance(source, ExternalCredential):
513
+ names += _external_found(profile, source, env, home, settings)
514
+ elif _present_kind(source, env, home) is not None:
515
+ names.append(_name(source))
516
+ return names
517
+
518
+
519
+ def _withheld(
520
+ profile: ProviderProfile,
521
+ env: Mapping[str, str],
522
+ home: Path,
523
+ present: list[str],
524
+ delivered: set[str],
525
+ ) -> tuple[str, ...]:
526
+ """The names of the host's auth state a plan delivering *delivered* leaves out."""
527
+ on_host = {name for name in _host_auth_names(profile) if _set(env, name)}
528
+ on_host |= {path for path in profile.auth_files if (home / path).is_file()}
529
+ on_host |= set(present)
530
+ return tuple(sorted(on_host - delivered))
531
+
532
+
533
+ def _delivered(env: Mapping[str, str], files: tuple[StagedCredentialFile, ...]) -> set[str]:
534
+ return set(env) | {staged.home_path for staged in files}
535
+
536
+
537
+ def _credential_env_names(profile: ProviderProfile) -> set[str]:
538
+ names: set[str] = set()
539
+ for source in profile.credential_sources:
540
+ if isinstance(source, EnvCredential):
541
+ names.add(source.name)
542
+ elif isinstance(source, ExternalCredential):
543
+ names.update(source.env)
544
+ return names
545
+
546
+
289
547
  def _present_kind(
290
- source: CredentialSource, env: Mapping[str, str], home: Path
548
+ source: _DeliverableSource, env: Mapping[str, str], home: Path
291
549
  ) -> CredentialKind | None:
292
550
  if isinstance(source, EnvCredential):
293
551
  return source.kind if _set(env, source.name) else None
@@ -304,7 +562,7 @@ def _set(env: Mapping[str, str], name: str) -> bool:
304
562
  return bool(value and value.strip())
305
563
 
306
564
 
307
- def _name(source: CredentialSource) -> str:
565
+ def _name(source: _DeliverableSource) -> str:
308
566
  return source.name if isinstance(source, EnvCredential) else source.path
309
567
 
310
568
 
@@ -326,7 +584,8 @@ def _unavailable(
326
584
  wanted = [
327
585
  _name(source)
328
586
  for source in profile.credential_sources
329
- if source.kind is None or source.kind in allowed
587
+ if not isinstance(source, ExternalCredential)
588
+ and (source.kind is None or source.kind in allowed)
330
589
  ]
331
590
  found = f"; present but not allowed: {', '.join(present)}" if present else ""
332
591
  return (
@@ -16,7 +16,7 @@ from .permissions import NativeMode
16
16
  if TYPE_CHECKING:
17
17
  from collections.abc import Mapping
18
18
 
19
- from .auth import CredentialSource, EndpointSpec
19
+ from .auth import CredentialSource, EndpointSpec, SettingsSpec
20
20
 
21
21
 
22
22
  class McpMechanism(Enum):
@@ -224,10 +224,16 @@ class ProviderProfile:
224
224
  supports_steer: bool = False
225
225
  #: The credentials the CLI can log in with, in the CLI's own precedence
226
226
  #: order (0.16.0+). ``plan_auth`` walks them to choose the one credential
227
- #: a caller hands the CLI. Every variable is in ``auth_env_vars`` and every
228
- #: file in ``credential_files``. Empty means the CLI's credentials cannot
229
- #: be classified, so no credential policy can be enforced for it.
227
+ #: a caller hands the CLI, and ``host_auth_plan`` (0.17.0+) to find the
228
+ #: one the CLI would pick by itself. Every ``EnvCredential`` variable is in
229
+ #: ``auth_env_vars`` and every ``FileCredential`` file in
230
+ #: ``credential_files``; an ``ExternalCredential`` (0.17.0+) is a way to
231
+ #: log in that no plan can hand over. Empty means the CLI's credentials
232
+ #: cannot be classified, so no credential policy can be enforced for it.
230
233
  credential_sources: tuple[CredentialSource, ...] = ()
231
234
  #: How the CLI is pointed at another API endpoint, or ``None`` if it
232
235
  #: cannot be (0.16.0+).
233
236
  endpoint: EndpointSpec | None = None
237
+ #: The settings files the CLI reads at start that can change how it logs
238
+ #: in, or ``None`` if it has none (0.17.0+).
239
+ settings: SettingsSpec | None = None
@@ -9,7 +9,9 @@ from agentshim.core.auth import (
9
9
  CredentialKind,
10
10
  EndpointSpec,
11
11
  EnvCredential,
12
+ ExternalCredential,
12
13
  FileCredential,
14
+ SettingsSpec,
13
15
  )
14
16
  from agentshim.core.errors import FailureKind, ProviderCapabilityError, SessionResumeError
15
17
  from agentshim.core.mcp import HttpMcpServer, NoopInstallation, StdioMcpServer, install_config_file
@@ -110,16 +112,33 @@ PROFILE = ProviderProfile(
110
112
  ".claude.json",
111
113
  ),
112
114
  credential_files=(".claude/.credentials.json",),
113
- # Precedence as ``claude auth status`` reports it: a bearer token beats an
114
- # API key, which beats a subscription token (``claude setup-token``),
115
- # which beats the stored Claude.ai login.
115
+ # Precedence as Claude Code documents it ("Authentication precedence",
116
+ # code.claude.com/docs/en/iam): a cloud provider selected by variable,
117
+ # then a bearer token, an API key, the ``apiKeyHelper`` setting, a
118
+ # subscription token (``claude setup-token``), an Anthropic profile or
119
+ # federation credential, and last the stored Claude.ai login. Checked
120
+ # against 2.1.300: ``apiKeyHelper`` beats CLAUDE_CODE_OAUTH_TOKEN.
116
121
  credential_sources=(
122
+ ExternalCredential(
123
+ "a cloud provider",
124
+ env=("CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY"),
125
+ ),
117
126
  EnvCredential("ANTHROPIC_AUTH_TOKEN", CredentialKind.API_KEY),
118
127
  EnvCredential("ANTHROPIC_API_KEY", CredentialKind.API_KEY),
128
+ ExternalCredential("an apiKeyHelper command", settings_keys=("apiKeyHelper",)),
119
129
  EnvCredential("CLAUDE_CODE_OAUTH_TOKEN", CredentialKind.SUBSCRIPTION),
130
+ ExternalCredential(
131
+ "an Anthropic profile or federation credential",
132
+ env=("ANTHROPIC_PROFILE", "ANTHROPIC_FEDERATION_RULE_ID"),
133
+ paths=(".config/anthropic/configs", ".config/anthropic/active_config"),
134
+ ),
120
135
  FileCredential(".claude/.credentials.json", kind=CredentialKind.SUBSCRIPTION),
121
136
  ),
122
137
  endpoint=EndpointSpec("ANTHROPIC_BASE_URL", companion_env=("ANTHROPIC_CUSTOM_HEADERS",)),
138
+ # User settings; the ``env`` block overrides the process environment
139
+ # (checked against 2.1.300: a settings ANTHROPIC_BASE_URL beats an
140
+ # exported one).
141
+ settings=SettingsSpec((".claude/settings.json", ".claude/settings.local.json"), env_key="env"),
123
142
  mcp_config_file=MCP_CONFIG_FILENAME,
124
143
  )
125
144
 
@@ -18,7 +18,13 @@ from __future__ import annotations
18
18
 
19
19
  from typing import TYPE_CHECKING, Any
20
20
 
21
- from agentshim.core.auth import CredentialKind, EnvCredential, FileCredential
21
+ from agentshim.core.auth import (
22
+ CredentialKind,
23
+ EnvCredential,
24
+ ExternalCredential,
25
+ FileCredential,
26
+ SettingsSpec,
27
+ )
22
28
  from agentshim.core.errors import ProviderCapabilityError, SessionResumeError
23
29
  from agentshim.core.mcp import (
24
30
  HttpMcpServer,
@@ -82,11 +88,22 @@ PROFILE = ProviderProfile(
82
88
  ".gemini/.env",
83
89
  ),
84
90
  credential_files=(".gemini/oauth_creds.json",),
91
+ # Gemini CLI uses the auth type selected in settings (or by a
92
+ # GOOGLE_GENAI_USE_* variable) before any key in the environment, and
93
+ # loads a ``.gemini/.env`` into its environment; none of that is
94
+ # modelled, so any of it makes the host login unplannable.
85
95
  credential_sources=(
96
+ ExternalCredential(
97
+ "an auth type selected in settings, by variable, or in a .env file",
98
+ env=("GOOGLE_GENAI_USE_VERTEXAI", "GOOGLE_GENAI_USE_GCA"),
99
+ paths=(".gemini/.env",),
100
+ settings_keys=("security", "selectedAuthType"),
101
+ ),
86
102
  EnvCredential("GEMINI_API_KEY", CredentialKind.API_KEY),
87
103
  EnvCredential("GOOGLE_API_KEY", CredentialKind.API_KEY),
88
104
  FileCredential(".gemini/oauth_creds.json", kind=CredentialKind.SUBSCRIPTION),
89
105
  ),
106
+ settings=SettingsSpec((".gemini/settings.json",)),
90
107
  mcp_config_file=MCP_CONFIG_FILENAME,
91
108
  )
92
109
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentshim"
3
- version = "0.16.0"
3
+ version = "0.17.0"
4
4
  description = "Provider-agnostic coding-agent CLI shims"
5
5
  readme = "README.md"
6
6
  license = "MIT"
File without changes
File without changes
File without changes