orchid-cli 1.1.4__tar.gz → 1.2.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.
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/AGENTS.md +12 -5
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/CHANGELOG.md +25 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/PKG-INFO +15 -11
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/README.md +13 -9
- orchid_cli-1.2.0/orchid_cli/_typer_async.py +40 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/auth/config.py +14 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/auth/flow.py +1 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/auth/middleware.py +60 -6
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/auth/pkce.py +81 -2
- orchid_cli-1.2.0/orchid_cli/auth/token_store.py +234 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/bootstrap.py +19 -0
- orchid_cli-1.2.0/orchid_cli/commands/_chat_helpers.py +32 -0
- orchid_cli-1.2.0/orchid_cli/commands/_chat_interactive.py +192 -0
- orchid_cli-1.2.0/orchid_cli/commands/_chat_send.py +249 -0
- orchid_cli-1.2.0/orchid_cli/commands/_session.py +74 -0
- orchid_cli-1.2.0/orchid_cli/commands/_skill_guardrails.py +113 -0
- orchid_cli-1.2.0/orchid_cli/commands/_skill_md.py +197 -0
- orchid_cli-1.2.0/orchid_cli/commands/_skill_text.py +16 -0
- orchid_cli-1.2.0/orchid_cli/commands/_skill_tools.py +216 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/commands/auth.py +26 -10
- orchid_cli-1.2.0/orchid_cli/commands/chat.py +238 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/commands/index.py +28 -54
- orchid_cli-1.2.0/orchid_cli/commands/mcp.py +124 -0
- orchid_cli-1.2.0/orchid_cli/commands/skill.py +87 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/pyproject.toml +2 -2
- orchid_cli-1.2.0/tests/test_bootstrap_warm.py +62 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_commands_chat.py +13 -13
- orchid_cli-1.2.0/tests/test_resolve_session.py +109 -0
- orchid_cli-1.2.0/tests/test_token_store_security.py +156 -0
- orchid_cli-1.1.4/orchid_cli/auth/token_store.py +0 -112
- orchid_cli-1.1.4/orchid_cli/commands/chat.py +0 -714
- orchid_cli-1.1.4/orchid_cli/commands/mcp.py +0 -300
- orchid_cli-1.1.4/orchid_cli/commands/skill.py +0 -630
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/.editorconfig +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/.github/workflows/ci.yml +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/.gitignore +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/.gitlint +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/.pre-commit-config.yaml +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/CLAUDE.md +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/CONTRIBUTING.md +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/LICENSE +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/icon.svg +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/__init__.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/_output.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/auth/__init__.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/auth/oidc.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/commands/__init__.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/commands/_tool_metadata.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/commands/config.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/main.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/slash_commands.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/conftest.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/fixtures/__init__.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/fixtures/fake_tools_dates.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/fixtures/fake_tools_math.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_auth.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_bootstrap.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_browser_opener.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_chat_builtin_slash.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_commands_config.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_commands_skill.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_middleware_seams.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_output.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_pkce.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_slash_commands.py +0 -0
- {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_tool_metadata.py +0 -0
|
@@ -17,11 +17,14 @@ orchid-cli/
|
|
|
17
17
|
token_store.py Secure token persistence (~/.orchid/tokens.json)
|
|
18
18
|
middleware.py Token refresh + OrchidAuthContext builder
|
|
19
19
|
commands/
|
|
20
|
+
_session.py resolve_session(config_path) -> (Orchid, OrchidAuthContext) +
|
|
21
|
+
warm_for_user — single chokepoint for bootstrap + auth + MCP warm
|
|
20
22
|
auth.py login, logout, status subcommands
|
|
21
23
|
chat.py Full CRUD: create, list, delete, history, send, interactive, rename, share
|
|
22
24
|
config.py validate command (checks agents.yaml)
|
|
23
|
-
index.py
|
|
24
|
-
mcp.py MCP OAuth:
|
|
25
|
+
index.py file/dir/text/json-file commands (RAG ingestion)
|
|
26
|
+
mcp.py MCP OAuth: status, revoke per-server tokens
|
|
27
|
+
(authorize flow runs through the API gateway, not the CLI)
|
|
25
28
|
skill.py generate command (Claude Code skills from agents.yaml)
|
|
26
29
|
pyproject.toml
|
|
27
30
|
```
|
|
@@ -52,10 +55,12 @@ orchid-cli/
|
|
|
52
55
|
|
|
53
56
|
6. **Config resolution:** CLI args > env vars > `orchid.yml` > hardcoded defaults.
|
|
54
57
|
|
|
55
|
-
7. **OAuth auth is self-contained in `auth/`.** The `auth/` subpackage handles the full OAuth 2.0 Authorization Code + PKCE flow. No OAuth logic in `chat.py`, `bootstrap.py`, or any other module. Chat commands call `get_auth_context(config_path)` which returns either a real OAuth-backed `OrchidAuthContext` or the dev fallback — callers don't know or care which.
|
|
58
|
+
7. **OAuth auth is self-contained in `auth/`.** The `auth/` subpackage handles the full OAuth 2.0 Authorization Code + PKCE flow. No OAuth logic in `chat.py`, `bootstrap.py`, or any other module. Chat commands call `get_auth_context(config_path)` which returns either a real OAuth-backed `OrchidAuthContext` or the dev fallback — callers don't know or care which. **The CLI is an independent OAuth client** — it runs its own dance against the upstream IdP and is NOT one of the downstream consumers covered by the [auth-centralisation roadmap](../.knowledge/auth-centralisation.md) (which targets the MCP gateway + Next.js frontends).
|
|
56
59
|
|
|
57
60
|
8. **Token storage at `~/.orchid/tokens.json`.** Permissions set to `0o600` (owner-only). Tokens are keyed by `client_id`, supporting multiple providers. Refresh tokens are used automatically when the access token expires.
|
|
58
61
|
|
|
62
|
+
9. **Commands go through `commands/_session.py:resolve_session`.** Every command that needs both an `Orchid` instance and a per-user `OrchidAuthContext` calls `resolve_session(config_path)` (or its `session_context` async-context-manager wrapper), which bootstraps the framework, resolves auth, and warms `passthrough` / `oauth` MCP capability caches once per `(tenant_key, user_id)`. The interactive REPL stays alive across many turns; the warmer's idempotency check makes subsequent loop iterations a no-op. Failures in the per-user warm are logged and ignored — the chat still works, it just pays the lazy discovery cost on first tool call. `bootstrap()` itself warms `auth.mode: none` servers up front (no user identity needed).
|
|
63
|
+
|
|
59
64
|
## Commands
|
|
60
65
|
|
|
61
66
|
```bash
|
|
@@ -76,14 +81,16 @@ orchid chat share --config <path> <id> # Promote RAG to user scope
|
|
|
76
81
|
|
|
77
82
|
# MCP server OAuth
|
|
78
83
|
orchid mcp status --config <path> # Show OAuth status for MCP servers
|
|
79
|
-
orchid mcp authorize <server> --config <path> # Authorize via browser (PKCE)
|
|
80
84
|
orchid mcp revoke <server> --config <path> # Revoke stored token
|
|
81
85
|
|
|
82
86
|
# Config
|
|
83
87
|
orchid config validate <agents.yaml> # Validate agent config
|
|
84
88
|
|
|
85
89
|
# RAG indexing
|
|
86
|
-
orchid index
|
|
90
|
+
orchid index file <path> -n <namespace> --config <path> # Index a single file
|
|
91
|
+
orchid index dir <path> -n <namespace> --config <path> # Index a directory
|
|
92
|
+
orchid index text "..." -n <namespace> --config <path> # Index inline text
|
|
93
|
+
orchid index json-file <path> -n <namespace> --config <path> # Bulk-index JSON entries
|
|
87
94
|
|
|
88
95
|
# Skill generation (Claude Code)
|
|
89
96
|
orchid skill generate <agents.yaml> # Generate Claude Code skills
|
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- version list -->
|
|
4
4
|
|
|
5
|
+
## v1.2.0 (2026-04-29)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
- **cli**: Centralize session handling and warm-up lifecycle
|
|
10
|
+
([`cfe9c2d`](https://github.com/gadz82/orchid-cli/commit/cfe9c2de1cb39cd6002ff9a49bda2253ea25eee9))
|
|
11
|
+
|
|
12
|
+
- **cli**: Ensure lock file path respects test monkeypatching of _ORCHID_DIR, enhance test for
|
|
13
|
+
temporary file cleanup
|
|
14
|
+
([`471de32`](https://github.com/gadz82/orchid-cli/commit/471de32d515a54a34d568f2c87a74f34c0b66ca1))
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
- **cli**: Clarify CLI OAuth independence and auth-centralisation roadmap context
|
|
19
|
+
([`da14aa6`](https://github.com/gadz82/orchid-cli/commit/da14aa6dc5b571e63ce7bd10a633133979ceba82))
|
|
20
|
+
|
|
21
|
+
### Features
|
|
22
|
+
|
|
23
|
+
- Bump orchid-ai dependency to >=1.4.0 in CLI and API
|
|
24
|
+
([`9ed4940`](https://github.com/gadz82/orchid-cli/commit/9ed49401fe3c5337bef84a5c235e9bdc4bdaac3f))
|
|
25
|
+
|
|
26
|
+
- **cli**: Enhance chat commands and RAG indexing, improve security for token storage
|
|
27
|
+
([`74dac43`](https://github.com/gadz82/orchid-cli/commit/74dac433ce7731374f4bd4366cbee6c7ecf1dfc8))
|
|
28
|
+
|
|
29
|
+
|
|
5
30
|
## v1.1.4 (2026-04-22)
|
|
6
31
|
|
|
7
32
|
### Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: orchid-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Orchid CLI — command-line interface for the Orchid agent framework
|
|
5
5
|
Project-URL: Homepage, https://github.com/gadz82/orchid
|
|
6
6
|
Project-URL: Repository, https://github.com/gadz82/orchid
|
|
@@ -26,7 +26,7 @@ Classifier: Typing :: Typed
|
|
|
26
26
|
Requires-Python: >=3.11
|
|
27
27
|
Requires-Dist: httpx>=0.28.0
|
|
28
28
|
Requires-Dist: langchain-core>=0.3.0
|
|
29
|
-
Requires-Dist: orchid-ai>=1.
|
|
29
|
+
Requires-Dist: orchid-ai>=1.4.0
|
|
30
30
|
Requires-Dist: pydantic-settings>=2.7.0
|
|
31
31
|
Requires-Dist: pyyaml>=6.0
|
|
32
32
|
Requires-Dist: rich>=13.0
|
|
@@ -164,24 +164,17 @@ Manage per-server OAuth for MCP servers that declare `auth.mode: oauth` in `agen
|
|
|
164
164
|
# Show authorization status for every OAuth-enabled MCP server
|
|
165
165
|
orchid mcp status -c orchid.yml
|
|
166
166
|
|
|
167
|
-
# Authorize a specific MCP server via browser (PKCE flow)
|
|
168
|
-
orchid mcp authorize <server-name> -c orchid.yml
|
|
169
|
-
orchid mcp authorize <server-name> -c orchid.yml --timeout 180
|
|
170
|
-
|
|
171
167
|
# Revoke the stored token for a server
|
|
172
168
|
orchid mcp revoke <server-name> -c orchid.yml
|
|
173
169
|
```
|
|
174
170
|
|
|
175
|
-
|
|
171
|
+
Authorization itself runs through the API gateway's OAuth callback — the CLI's job is only to surface status and let the user revoke a stored token.
|
|
176
172
|
|
|
177
173
|
### RAG Indexing
|
|
178
174
|
|
|
179
|
-
|
|
175
|
+
Index documents into the vector store, **on startup or any time later**:
|
|
180
176
|
|
|
181
177
|
```bash
|
|
182
|
-
# Run the registered StaticIndexer (consumer-provided seed data)
|
|
183
|
-
orchid index seed -c orchid.yml --tenant my-tenant
|
|
184
|
-
|
|
185
178
|
# Index a single document (PDF, DOCX, XLSX, CSV, TXT, MD, PNG, JPG)
|
|
186
179
|
orchid index file ./docs/faq.pdf -n support -c orchid.yml
|
|
187
180
|
|
|
@@ -354,6 +347,17 @@ When the access token expires and a refresh token is available, the CLI refreshe
|
|
|
354
347
|
|
|
355
348
|
When `identity_resolver_class` is configured, the CLI calls the resolver after login to populate `tenant_key` and `user_id` from the OAuth token. These identity fields are cached in the token file so subsequent commands don't need the resolver. See the [orchid OrchidIdentityResolver ABC](../orchid/orchid_ai/core/identity.py) for the interface.
|
|
356
349
|
|
|
350
|
+
> **Note:** the CLI is an **independent OAuth client** — it runs its own
|
|
351
|
+
> authorization-code + PKCE dance against the upstream IdP and calls the
|
|
352
|
+
> identity resolver locally. It does NOT use the
|
|
353
|
+
> centralised `/auth/exchange-code` / `/auth/resolve-identity` /
|
|
354
|
+
> `/auth/refresh-token` endpoints that the MCP gateway uses (Phases 1–5
|
|
355
|
+
> of the [auth-centralisation roadmap](../.knowledge/auth-centralisation.md)).
|
|
356
|
+
> The CLI ships with the upstream secret `client_id` baked into its config
|
|
357
|
+
> because it's a desktop app, not a network service. A future migration
|
|
358
|
+
> could route the CLI through the same endpoints to remove the
|
|
359
|
+
> CLI-side `client_secret` / userinfo coupling — out of scope today.
|
|
360
|
+
|
|
357
361
|
### Dev Fallback
|
|
358
362
|
|
|
359
363
|
When `auth.dev_bypass: true` or `auth.cli` is absent, the CLI uses a dummy token (`cli-token`, tenant=`cli`, user=`cli-user`). This is fully backward compatible -- existing configs without OAuth continue to work unchanged.
|
|
@@ -122,24 +122,17 @@ Manage per-server OAuth for MCP servers that declare `auth.mode: oauth` in `agen
|
|
|
122
122
|
# Show authorization status for every OAuth-enabled MCP server
|
|
123
123
|
orchid mcp status -c orchid.yml
|
|
124
124
|
|
|
125
|
-
# Authorize a specific MCP server via browser (PKCE flow)
|
|
126
|
-
orchid mcp authorize <server-name> -c orchid.yml
|
|
127
|
-
orchid mcp authorize <server-name> -c orchid.yml --timeout 180
|
|
128
|
-
|
|
129
125
|
# Revoke the stored token for a server
|
|
130
126
|
orchid mcp revoke <server-name> -c orchid.yml
|
|
131
127
|
```
|
|
132
128
|
|
|
133
|
-
|
|
129
|
+
Authorization itself runs through the API gateway's OAuth callback — the CLI's job is only to surface status and let the user revoke a stored token.
|
|
134
130
|
|
|
135
131
|
### RAG Indexing
|
|
136
132
|
|
|
137
|
-
|
|
133
|
+
Index documents into the vector store, **on startup or any time later**:
|
|
138
134
|
|
|
139
135
|
```bash
|
|
140
|
-
# Run the registered StaticIndexer (consumer-provided seed data)
|
|
141
|
-
orchid index seed -c orchid.yml --tenant my-tenant
|
|
142
|
-
|
|
143
136
|
# Index a single document (PDF, DOCX, XLSX, CSV, TXT, MD, PNG, JPG)
|
|
144
137
|
orchid index file ./docs/faq.pdf -n support -c orchid.yml
|
|
145
138
|
|
|
@@ -312,6 +305,17 @@ When the access token expires and a refresh token is available, the CLI refreshe
|
|
|
312
305
|
|
|
313
306
|
When `identity_resolver_class` is configured, the CLI calls the resolver after login to populate `tenant_key` and `user_id` from the OAuth token. These identity fields are cached in the token file so subsequent commands don't need the resolver. See the [orchid OrchidIdentityResolver ABC](../orchid/orchid_ai/core/identity.py) for the interface.
|
|
314
307
|
|
|
308
|
+
> **Note:** the CLI is an **independent OAuth client** — it runs its own
|
|
309
|
+
> authorization-code + PKCE dance against the upstream IdP and calls the
|
|
310
|
+
> identity resolver locally. It does NOT use the
|
|
311
|
+
> centralised `/auth/exchange-code` / `/auth/resolve-identity` /
|
|
312
|
+
> `/auth/refresh-token` endpoints that the MCP gateway uses (Phases 1–5
|
|
313
|
+
> of the [auth-centralisation roadmap](../.knowledge/auth-centralisation.md)).
|
|
314
|
+
> The CLI ships with the upstream secret `client_id` baked into its config
|
|
315
|
+
> because it's a desktop app, not a network service. A future migration
|
|
316
|
+
> could route the CLI through the same endpoints to remove the
|
|
317
|
+
> CLI-side `client_secret` / userinfo coupling — out of scope today.
|
|
318
|
+
|
|
315
319
|
### Dev Fallback
|
|
316
320
|
|
|
317
321
|
When `auth.dev_bypass: true` or `auth.cli` is absent, the CLI uses a dummy token (`cli-token`, tenant=`cli`, user=`cli-user`). This is fully backward compatible -- existing configs without OAuth continue to work unchanged.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Tiny decorator that lets Typer call ``async def`` command bodies.
|
|
2
|
+
|
|
3
|
+
Vanilla Typer (and the underlying Click) don't await coroutines — an
|
|
4
|
+
``async def`` command would print ``<coroutine ...>`` and exit. The
|
|
5
|
+
``async_command`` decorator wraps a coroutine in a sync trampoline that
|
|
6
|
+
Typer can register as a normal command, so command files stop carrying
|
|
7
|
+
the boilerplate pair of ``def cmd(...): asyncio.run(_cmd(...))`` plus
|
|
8
|
+
``async def _cmd(...)``.
|
|
9
|
+
|
|
10
|
+
Usage
|
|
11
|
+
-----
|
|
12
|
+
|
|
13
|
+
@app.command()
|
|
14
|
+
@async_command
|
|
15
|
+
async def create(title: str = "") -> None:
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
The two decorators must be in this order: ``@app.command()`` outermost
|
|
19
|
+
(Typer registers the wrapper), ``@async_command`` inside (returns a
|
|
20
|
+
sync callable for Typer to register).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import functools
|
|
27
|
+
from collections.abc import Awaitable
|
|
28
|
+
from typing import Any, Callable, TypeVar
|
|
29
|
+
|
|
30
|
+
T = TypeVar("T")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def async_command(func: Callable[..., Awaitable[T]]) -> Callable[..., T]:
|
|
34
|
+
"""Wrap an ``async def`` Typer command body in :func:`asyncio.run`."""
|
|
35
|
+
|
|
36
|
+
@functools.wraps(func)
|
|
37
|
+
def wrapper(*args: Any, **kwargs: Any) -> T:
|
|
38
|
+
return asyncio.run(func(*args, **kwargs))
|
|
39
|
+
|
|
40
|
+
return wrapper
|
|
@@ -36,6 +36,13 @@ class OAuthProviderConfig:
|
|
|
36
36
|
identity_resolver_class: str = ""
|
|
37
37
|
# Optional domain (passed to OrchidIdentityResolver.resolve).
|
|
38
38
|
domain: str = ""
|
|
39
|
+
# Optional ``client_secret`` for OAuth servers that register the CLI
|
|
40
|
+
# as a CONFIDENTIAL client (PKCE alone is not enough — the
|
|
41
|
+
# authorization server requires both the verifier AND the secret on
|
|
42
|
+
# the token endpoint). Pure-public PKCE setups leave this empty.
|
|
43
|
+
# Mirrored to refresh-token grants too — see
|
|
44
|
+
# :func:`orchid_cli.auth.middleware._refresh_token`.
|
|
45
|
+
client_secret: str = ""
|
|
39
46
|
|
|
40
47
|
|
|
41
48
|
def load_oauth_config(config_path: str) -> OAuthProviderConfig | None:
|
|
@@ -76,6 +83,11 @@ def load_oauth_config(config_path: str) -> OAuthProviderConfig | None:
|
|
|
76
83
|
issuer = cli_section.get("issuer", "")
|
|
77
84
|
auth_endpoint = cli_section.get("authorization_endpoint", "")
|
|
78
85
|
token_endpoint = cli_section.get("token_endpoint", "")
|
|
86
|
+
# Optional secret for OAuth servers that require the CLI to
|
|
87
|
+
# authenticate as a CONFIDENTIAL client (PKCE alone is not enough
|
|
88
|
+
# — the token endpoint demands ``client_secret`` too). Empty
|
|
89
|
+
# string for pure-public PKCE clients.
|
|
90
|
+
client_secret = cli_section.get("client_secret", "")
|
|
79
91
|
|
|
80
92
|
# Carry forward top-level auth fields for identity resolution.
|
|
81
93
|
identity_resolver_class = auth_section.get("identity_resolver_class", "")
|
|
@@ -89,6 +101,7 @@ def load_oauth_config(config_path: str) -> OAuthProviderConfig | None:
|
|
|
89
101
|
issuer=issuer,
|
|
90
102
|
identity_resolver_class=identity_resolver_class,
|
|
91
103
|
domain=domain,
|
|
104
|
+
client_secret=client_secret,
|
|
92
105
|
)
|
|
93
106
|
|
|
94
107
|
|
|
@@ -136,6 +149,7 @@ async def discover_oidc_endpoints(config: OAuthProviderConfig) -> OAuthProviderC
|
|
|
136
149
|
issuer=config.issuer,
|
|
137
150
|
identity_resolver_class=config.identity_resolver_class,
|
|
138
151
|
domain=config.domain,
|
|
152
|
+
client_secret=config.client_secret,
|
|
139
153
|
)
|
|
140
154
|
|
|
141
155
|
|
|
@@ -52,6 +52,7 @@ async def run_login_flow(
|
|
|
52
52
|
"auth_endpoint": config.authorization_endpoint,
|
|
53
53
|
"token_endpoint": config.token_endpoint,
|
|
54
54
|
"client_id": config.client_id,
|
|
55
|
+
"client_secret": config.client_secret,
|
|
55
56
|
"scopes": config.scopes,
|
|
56
57
|
"timeout": timeout,
|
|
57
58
|
"success_title": "Authentication successful",
|
|
@@ -101,17 +101,62 @@ async def get_auth_context(
|
|
|
101
101
|
logger.warning("[CLI Auth] Token expired and no refresh token. Run 'orchid auth login'.")
|
|
102
102
|
return _DEV_AUTH
|
|
103
103
|
|
|
104
|
-
#
|
|
104
|
+
# ── Fast path — rebuild the typed subclass from the cache ──
|
|
105
|
+
#
|
|
106
|
+
# When the most recent login resolved an identity, both
|
|
107
|
+
# ``auth_class`` (FQN) and ``auth_state`` (the dict produced by
|
|
108
|
+
# ``OrchidAuthContext.to_storage_dict()``) were persisted on the
|
|
109
|
+
# token. We rebuild the typed instance directly here — no
|
|
110
|
+
# network round-trip — and pass the fresh ``access_token`` /
|
|
111
|
+
# ``expires_at`` from the (possibly just-refreshed) token in
|
|
112
|
+
# case they rolled over.
|
|
113
|
+
if token.auth_class and token.auth_state:
|
|
114
|
+
try:
|
|
115
|
+
from orchid_ai.utils import import_class
|
|
116
|
+
|
|
117
|
+
cls = import_class(token.auth_class)
|
|
118
|
+
return cls.from_storage_dict(
|
|
119
|
+
access_token=token.access_token,
|
|
120
|
+
expires_at=token.expires_at,
|
|
121
|
+
state=dict(token.auth_state),
|
|
122
|
+
)
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
# Cache restoration failed — most likely the resolver's
|
|
125
|
+
# subclass moved or its ``from_storage_dict`` got a
|
|
126
|
+
# breaking change. Log loudly and fall through to the
|
|
127
|
+
# resolver (slow path); the operator will see the
|
|
128
|
+
# warning and can ``orchid auth login`` once to refresh
|
|
129
|
+
# the cache shape.
|
|
130
|
+
logger.warning(
|
|
131
|
+
"[CLI Auth] Cached identity rebuild failed (%s: %s) — falling back to live resolver",
|
|
132
|
+
type(exc).__name__,
|
|
133
|
+
exc,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# ── Slow path — call the resolver, then cache for next time ──
|
|
137
|
+
#
|
|
138
|
+
# Used on first login (cache empty), after a manual ``orchid
|
|
139
|
+
# auth logout``, OR when the cached subclass-class moved and we
|
|
140
|
+
# had to bail out of the fast path above.
|
|
105
141
|
auth = OrchidAuthContext(
|
|
106
142
|
access_token=token.access_token,
|
|
107
143
|
tenant_key=token.tenant_key or "default",
|
|
108
144
|
user_id=token.user_id or "cli-user",
|
|
109
145
|
expires_at=token.expires_at,
|
|
146
|
+
extra=dict(token.extra) if token.extra else None,
|
|
110
147
|
)
|
|
111
|
-
|
|
112
|
-
# Optionally resolve identity via OrchidIdentityResolver.
|
|
113
|
-
if cfg.identity_resolver_class and (not token.tenant_key or not token.user_id):
|
|
148
|
+
if cfg.identity_resolver_class:
|
|
114
149
|
auth = await _resolve_identity(cfg, token, auth)
|
|
150
|
+
# Persist the freshly-resolved identity so the NEXT command
|
|
151
|
+
# takes the fast path above. Mirror the same logic as
|
|
152
|
+
# ``commands/auth.py:_resolve_and_store_identity`` so a
|
|
153
|
+
# one-off ``orchid chat`` against a token that lost its
|
|
154
|
+
# cache (e.g. legacy tokens written before this feature
|
|
155
|
+
# existed) self-heals on first use.
|
|
156
|
+
if type(auth) is not OrchidAuthContext:
|
|
157
|
+
token.auth_class = f"{type(auth).__module__}.{type(auth).__qualname__}"
|
|
158
|
+
token.auth_state = auth.to_storage_dict()
|
|
159
|
+
saver(cfg.client_id, token)
|
|
115
160
|
|
|
116
161
|
return auth
|
|
117
162
|
|
|
@@ -120,12 +165,21 @@ async def _refresh_token(
|
|
|
120
165
|
config: OAuthProviderConfig,
|
|
121
166
|
token: StoredToken,
|
|
122
167
|
) -> StoredToken:
|
|
123
|
-
"""Use the refresh_token grant to obtain a new access token.
|
|
124
|
-
|
|
168
|
+
"""Use the refresh_token grant to obtain a new access token.
|
|
169
|
+
|
|
170
|
+
Mirrors :func:`exchange_code_for_tokens` — when the OAuth server
|
|
171
|
+
treats the CLI as a confidential client, the refresh grant ALSO
|
|
172
|
+
needs ``client_secret`` in the form body. Pure-public PKCE
|
|
173
|
+
deployments leave ``config.client_secret`` empty and the field is
|
|
174
|
+
omitted.
|
|
175
|
+
"""
|
|
176
|
+
payload: dict[str, str] = {
|
|
125
177
|
"grant_type": "refresh_token",
|
|
126
178
|
"refresh_token": token.refresh_token,
|
|
127
179
|
"client_id": config.client_id,
|
|
128
180
|
}
|
|
181
|
+
if config.client_secret:
|
|
182
|
+
payload["client_secret"] = config.client_secret
|
|
129
183
|
|
|
130
184
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
131
185
|
resp = await client.post(
|
|
@@ -28,7 +28,9 @@ import base64
|
|
|
28
28
|
import hashlib
|
|
29
29
|
import html
|
|
30
30
|
import logging
|
|
31
|
+
import os
|
|
31
32
|
import secrets
|
|
33
|
+
import shlex
|
|
32
34
|
import socket
|
|
33
35
|
import webbrowser
|
|
34
36
|
from dataclasses import dataclass
|
|
@@ -188,20 +190,36 @@ async def exchange_code_for_tokens(
|
|
|
188
190
|
code_verifier: str,
|
|
189
191
|
redirect_uri: str,
|
|
190
192
|
client_id: str,
|
|
193
|
+
client_secret: str = "",
|
|
191
194
|
timeout: float = 30.0,
|
|
192
195
|
) -> dict:
|
|
193
196
|
"""POST the authorization code to the token endpoint; return the JSON body.
|
|
194
197
|
|
|
198
|
+
``client_secret`` is optional — populate it when the OAuth server
|
|
199
|
+
treats the CLI as a CONFIDENTIAL client (PKCE alone isn't enough
|
|
200
|
+
— the token endpoint demands the secret as well). Sent as
|
|
201
|
+
``client_secret_post`` (form-body field) per RFC 6749 §2.3.1
|
|
202
|
+
because that's what every OAuth server we've seen accepts and it
|
|
203
|
+
doesn't require pre-flight knowledge of which auth method the
|
|
204
|
+
server supports.
|
|
205
|
+
|
|
195
206
|
Raises ``httpx.HTTPStatusError`` on non-2xx responses so callers can
|
|
196
207
|
distinguish transport errors from OAuth errors.
|
|
208
|
+
|
|
209
|
+
Note: the equivalent ``curl`` invocation is rendered by
|
|
210
|
+
:func:`run_pkce_flow` (pre-flight when ``ORCHID_AUTH_DEBUG_CURL``
|
|
211
|
+
is set, post-mortem on failure regardless). Putting the diagnostic
|
|
212
|
+
one level up keeps this primitive HTTP-only.
|
|
197
213
|
"""
|
|
198
|
-
payload = {
|
|
214
|
+
payload: dict[str, str] = {
|
|
199
215
|
"grant_type": "authorization_code",
|
|
200
216
|
"code": code,
|
|
201
217
|
"redirect_uri": redirect_uri,
|
|
202
218
|
"client_id": client_id,
|
|
203
219
|
"code_verifier": code_verifier,
|
|
204
220
|
}
|
|
221
|
+
if client_secret:
|
|
222
|
+
payload["client_secret"] = client_secret
|
|
205
223
|
|
|
206
224
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
207
225
|
resp = await client.post(
|
|
@@ -213,6 +231,33 @@ async def exchange_code_for_tokens(
|
|
|
213
231
|
return resp.json()
|
|
214
232
|
|
|
215
233
|
|
|
234
|
+
def _build_curl(url: str, body: dict[str, str]) -> str:
|
|
235
|
+
"""Render the equivalent ``curl`` command for a form-encoded POST.
|
|
236
|
+
|
|
237
|
+
Each field uses ``--data-urlencode`` so ``=`` / ``&`` / spaces
|
|
238
|
+
inside values can't break the line. Output is single-line and
|
|
239
|
+
backslash-continued so it pastes cleanly into a terminal.
|
|
240
|
+
"""
|
|
241
|
+
lines = [
|
|
242
|
+
f"curl -i -X POST {shlex.quote(url)} \\",
|
|
243
|
+
" -H 'Accept: application/json' \\",
|
|
244
|
+
" -H 'Content-Type: application/x-www-form-urlencoded' \\",
|
|
245
|
+
]
|
|
246
|
+
items = list(body.items())
|
|
247
|
+
for i, (k, v) in enumerate(items):
|
|
248
|
+
sep = " \\" if i < len(items) - 1 else ""
|
|
249
|
+
lines.append(f" --data-urlencode {shlex.quote(f'{k}={v}')}{sep}")
|
|
250
|
+
return "\n".join(
|
|
251
|
+
[
|
|
252
|
+
"",
|
|
253
|
+
"── orchid-cli token-exchange curl ─────────────",
|
|
254
|
+
*lines,
|
|
255
|
+
"───────────────────────────────────────────────",
|
|
256
|
+
"",
|
|
257
|
+
]
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
216
261
|
# ── High-level orchestration ──────────────────────────────────
|
|
217
262
|
|
|
218
263
|
|
|
@@ -238,6 +283,7 @@ async def run_pkce_flow(
|
|
|
238
283
|
token_endpoint: str,
|
|
239
284
|
client_id: str,
|
|
240
285
|
scopes: str,
|
|
286
|
+
client_secret: str = "",
|
|
241
287
|
timeout: float = 120.0,
|
|
242
288
|
browser_opener: BrowserOpener = webbrowser.open,
|
|
243
289
|
success_title: str = "Authorization complete",
|
|
@@ -293,6 +339,24 @@ async def run_pkce_flow(
|
|
|
293
339
|
if callback.state != state:
|
|
294
340
|
return PKCEFlowResult(success=False, error="State mismatch — possible CSRF")
|
|
295
341
|
|
|
342
|
+
# Build the exact request payload up-front so we can render the
|
|
343
|
+
# equivalent curl on failure (or when the debug env var is set).
|
|
344
|
+
exchange_payload: dict[str, str] = {
|
|
345
|
+
"grant_type": "authorization_code",
|
|
346
|
+
"code": callback.code,
|
|
347
|
+
"redirect_uri": redirect_uri,
|
|
348
|
+
"client_id": client_id,
|
|
349
|
+
"code_verifier": code_verifier,
|
|
350
|
+
}
|
|
351
|
+
if client_secret:
|
|
352
|
+
exchange_payload["client_secret"] = client_secret
|
|
353
|
+
|
|
354
|
+
debug_curl = os.environ.get("ORCHID_AUTH_DEBUG_CURL", "").strip().lower() not in ("", "0", "false", "no")
|
|
355
|
+
if debug_curl:
|
|
356
|
+
# Pre-flight dump — visible BEFORE the request goes out so the
|
|
357
|
+
# operator can copy it into another terminal and replay there.
|
|
358
|
+
print(_build_curl(token_endpoint, exchange_payload), flush=True)
|
|
359
|
+
|
|
296
360
|
try:
|
|
297
361
|
data = await exchange_code_for_tokens(
|
|
298
362
|
token_endpoint=token_endpoint,
|
|
@@ -300,9 +364,24 @@ async def run_pkce_flow(
|
|
|
300
364
|
code_verifier=code_verifier,
|
|
301
365
|
redirect_uri=redirect_uri,
|
|
302
366
|
client_id=client_id,
|
|
367
|
+
client_secret=client_secret,
|
|
303
368
|
)
|
|
304
369
|
except httpx.HTTPStatusError as exc:
|
|
305
|
-
|
|
370
|
+
# Surface the upstream OAuth error verbatim — without it, every
|
|
371
|
+
# 400 looks the same and there's no way to tell ``invalid_grant``
|
|
372
|
+
# apart from ``invalid_client`` apart from ``redirect_uri``
|
|
373
|
+
# mismatch. RFC 6749 §5.2 mandates the body be JSON with
|
|
374
|
+
# ``error`` + ``error_description`` fields, but some servers
|
|
375
|
+
# return text/html — try JSON first, fall back to raw text.
|
|
376
|
+
try:
|
|
377
|
+
body = exc.response.json()
|
|
378
|
+
detail = f"{body.get('error', '?')}: {body.get('error_description', body)}"
|
|
379
|
+
except Exception:
|
|
380
|
+
detail = (exc.response.text or "<empty body>")[:300]
|
|
381
|
+
return PKCEFlowResult(
|
|
382
|
+
success=False,
|
|
383
|
+
error=f"Token exchange failed (HTTP {exc.response.status_code}): {detail}",
|
|
384
|
+
)
|
|
306
385
|
except Exception as exc: # pragma: no cover — defensive
|
|
307
386
|
return PKCEFlowResult(success=False, error=f"Token exchange failed: {exc}")
|
|
308
387
|
|