induscode 0.1.0__py3-none-any.whl
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.
- induscode/__init__.py +56 -0
- induscode/addons/__init__.py +176 -0
- induscode/addons/contract.py +923 -0
- induscode/addons/dispatch/__init__.py +43 -0
- induscode/addons/dispatch/event_dispatcher.py +348 -0
- induscode/addons/dispatch/tool_interceptor.py +349 -0
- induscode/addons/host.py +469 -0
- induscode/addons/loader.py +314 -0
- induscode/addons/manifest.py +232 -0
- induscode/addons/surface.py +199 -0
- induscode/boot/__init__.py +108 -0
- induscode/boot/auth_vault.py +323 -0
- induscode/boot/boot.py +210 -0
- induscode/boot/contract.py +223 -0
- induscode/boot/invocation.py +117 -0
- induscode/boot/runners/__init__.py +42 -0
- induscode/boot/runners/link_runner.py +82 -0
- induscode/boot/runners/oneshot_runner.py +85 -0
- induscode/boot/runners/registry.py +46 -0
- induscode/boot/runners/repl_runner.py +340 -0
- induscode/boot/runners/session.py +549 -0
- induscode/boot/stages.py +198 -0
- induscode/boot/upgrade/__init__.py +36 -0
- induscode/boot/upgrade/apply.py +125 -0
- induscode/boot/upgrade/upgrades.py +136 -0
- induscode/briefing/__init__.py +115 -0
- induscode/briefing/compose.py +414 -0
- induscode/briefing/contract.py +528 -0
- induscode/briefing/macros.py +721 -0
- induscode/briefing/skills.py +417 -0
- induscode/capability_deck/__init__.py +233 -0
- induscode/capability_deck/bridge_ledger/__init__.py +66 -0
- induscode/capability_deck/bridge_ledger/key.py +181 -0
- induscode/capability_deck/bridge_ledger/ledger.py +276 -0
- induscode/capability_deck/bridge_ledger/network.py +336 -0
- induscode/capability_deck/builtin_bridge.py +358 -0
- induscode/capability_deck/cards/__init__.py +116 -0
- induscode/capability_deck/cards/bg_process.py +482 -0
- induscode/capability_deck/cards/memory.py +226 -0
- induscode/capability_deck/cards/saas.py +280 -0
- induscode/capability_deck/cards/task.py +256 -0
- induscode/capability_deck/cards/todo.py +312 -0
- induscode/capability_deck/contract.py +450 -0
- induscode/capability_deck/manifest.py +126 -0
- induscode/capability_deck/provision.py +217 -0
- induscode/channels/__init__.py +146 -0
- induscode/channels/contract.py +585 -0
- induscode/channels/framer.py +132 -0
- induscode/channels/link/__init__.py +50 -0
- induscode/channels/link/dialog.py +246 -0
- induscode/channels/link/driver.py +308 -0
- induscode/channels/link/server.py +217 -0
- induscode/channels/oneshot.py +178 -0
- induscode/channels/ops.py +140 -0
- induscode/channels/session_ops.py +172 -0
- induscode/conductor/__init__.py +240 -0
- induscode/conductor/catalog.py +309 -0
- induscode/conductor/conductor.py +1084 -0
- induscode/conductor/contract.py +1035 -0
- induscode/conductor/matcher.py +291 -0
- induscode/conductor/serialize.py +575 -0
- induscode/conductor/signal_hub.py +382 -0
- induscode/conductor/skill_parse.py +294 -0
- induscode/conductor/transcript_store.py +449 -0
- induscode/console/__init__.py +236 -0
- induscode/console/app.py +1677 -0
- induscode/console/components/__init__.py +62 -0
- induscode/console/components/banner.py +499 -0
- induscode/console/components/banner_sweep.py +188 -0
- induscode/console/components/emblem.py +181 -0
- induscode/console/components/status_bar.py +102 -0
- induscode/console/contract.py +836 -0
- induscode/console/input/__init__.py +107 -0
- induscode/console/input/chord.py +197 -0
- induscode/console/input/dir_reader.py +113 -0
- induscode/console/input/intents.py +258 -0
- induscode/console/input/providers.py +469 -0
- induscode/console/mount.py +137 -0
- induscode/console/overlays/__init__.py +94 -0
- induscode/console/overlays/auth.py +503 -0
- induscode/console/overlays/pickers.py +526 -0
- induscode/console/overlays/router.py +129 -0
- induscode/console/overlays/sessions.py +232 -0
- induscode/console/reducer.py +145 -0
- induscode/console/resume_picker.py +156 -0
- induscode/console/slash_commands/__init__.py +78 -0
- induscode/console/slash_commands/builtins.py +254 -0
- induscode/console/slash_commands/dynamic.py +217 -0
- induscode/console/slash_commands/integrations.py +949 -0
- induscode/console/slash_commands/transcript.py +404 -0
- induscode/console/slash_commands/workbench.py +430 -0
- induscode/console/startup.py +434 -0
- induscode/console/theme/__init__.py +44 -0
- induscode/console/theme/adapter.py +168 -0
- induscode/console/theme/palette.py +128 -0
- induscode/console/theme/resolve.py +123 -0
- induscode/console/theme/tokens.py +185 -0
- induscode/console_slash/__init__.py +111 -0
- induscode/console_slash/contract.py +185 -0
- induscode/console_slash/registry.py +140 -0
- induscode/console_slash/resolve.py +194 -0
- induscode/console_slash/shared.py +172 -0
- induscode/entry.py +108 -0
- induscode/insight/__init__.py +153 -0
- induscode/insight/collector.py +73 -0
- induscode/insight/replay.py +305 -0
- induscode/insight/wrapper.py +1115 -0
- induscode/kit/__init__.py +82 -0
- induscode/kit/clipboard_image.py +215 -0
- induscode/kit/external_editor.py +120 -0
- induscode/kit/image.py +188 -0
- induscode/kit/shell.py +89 -0
- induscode/kit/tool_fetch.py +288 -0
- induscode/launch/__init__.py +224 -0
- induscode/launch/catalog.py +310 -0
- induscode/launch/contract.py +569 -0
- induscode/launch/credentials.py +852 -0
- induscode/launch/invocation/__init__.py +39 -0
- induscode/launch/invocation/attachments.py +281 -0
- induscode/launch/invocation/flags.py +210 -0
- induscode/launch/invocation/read.py +369 -0
- induscode/launch/invocation/usage.py +110 -0
- induscode/launch/oauth.py +808 -0
- induscode/launch/packages.py +299 -0
- induscode/launch/pickers.py +291 -0
- induscode/py.typed +0 -0
- induscode/runtime_bridge/__init__.py +166 -0
- induscode/runtime_bridge/bridges/__init__.py +66 -0
- induscode/runtime_bridge/bridges/_drive.py +268 -0
- induscode/runtime_bridge/bridges/builtins.py +177 -0
- induscode/runtime_bridge/bridges/claude_cli.py +198 -0
- induscode/runtime_bridge/bridges/codex_cli.py +203 -0
- induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
- induscode/runtime_bridge/broker.py +397 -0
- induscode/runtime_bridge/contract.py +734 -0
- induscode/runtime_bridge/sink.py +351 -0
- induscode/sessions/__init__.py +25 -0
- induscode/sessions/contract.py +119 -0
- induscode/sessions/library.py +350 -0
- induscode/settings/__init__.py +47 -0
- induscode/settings/contract.py +313 -0
- induscode/settings/manager.py +268 -0
- induscode/transcript_export/__init__.py +109 -0
- induscode/transcript_export/contract.py +522 -0
- induscode/transcript_export/publish.py +455 -0
- induscode/transcript_export/sgr.py +566 -0
- induscode/transcript_export/template.py +319 -0
- induscode/transcript_export/theme_bridge.py +325 -0
- induscode/window_budget/__init__.py +76 -0
- induscode/window_budget/budget/__init__.py +26 -0
- induscode/window_budget/budget/estimate.py +273 -0
- induscode/window_budget/budget/gate.py +60 -0
- induscode/window_budget/budget/slice.py +145 -0
- induscode/window_budget/condenser.py +170 -0
- induscode/window_budget/contract.py +329 -0
- induscode/window_budget/summarize/__init__.py +33 -0
- induscode/window_budget/summarize/condense.py +212 -0
- induscode/window_budget/summarize/prompt.py +241 -0
- induscode/workspace/__init__.py +30 -0
- induscode/workspace/brand.py +96 -0
- induscode/workspace/locator.py +269 -0
- induscode-0.1.0.dist-info/METADATA +97 -0
- induscode-0.1.0.dist-info/RECORD +167 -0
- induscode-0.1.0.dist-info/WHEEL +4 -0
- induscode-0.1.0.dist-info/entry_points.txt +3 -0
- induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
- induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
"""OAuth integration — the browser-sign-in counterpart to the api-key flow.
|
|
2
|
+
|
|
3
|
+
**THE adapter** (port plan gap #1). The TS build leaned on the framework's
|
|
4
|
+
provider-object registry: each ``OAuthProviderInterface`` owned a whole
|
|
5
|
+
``login(callbacks)`` choreography, ``registerOAuthProvider`` populated a
|
|
6
|
+
registry at import time, and ``startOAuthLogin`` drove ``provider.login`` and
|
|
7
|
+
persisted the result. The Python framework ships no provider objects — its
|
|
8
|
+
OAuth surface is transport primitives
|
|
9
|
+
(:mod:`indusagi.llmgateway.credentials.oauth`:
|
|
10
|
+
:data:`~indusagi.llmgateway.credentials.oauth.OAUTH_PROVIDERS` /
|
|
11
|
+
:func:`~indusagi.llmgateway.credentials.oauth.oauth_config_for` /
|
|
12
|
+
:func:`~indusagi.llmgateway.credentials.oauth.build_auth_url` /
|
|
13
|
+
:func:`~indusagi.llmgateway.credentials.oauth.exchange_code` /
|
|
14
|
+
:func:`~indusagi.llmgateway.credentials.oauth.refresh_token`) plus PKCE
|
|
15
|
+
(:mod:`indusagi.llmgateway.credentials.pkce`). This module re-choreographs the
|
|
16
|
+
TS ``provider.login(callbacks)`` flow over those primitives:
|
|
17
|
+
|
|
18
|
+
1. mint a PKCE pair and a CSRF ``state``;
|
|
19
|
+
2. build the authorization URL and surface it through ``callbacks.on_auth``
|
|
20
|
+
(the credential command opens it via :func:`open_login_url`);
|
|
21
|
+
3. read the pasted authorization code (or full redirect URL) back through
|
|
22
|
+
``callbacks.on_prompt`` — the paste choreography, exactly as the framework
|
|
23
|
+
``auth_cli`` does it: no local callback HTTP server exists anywhere in this
|
|
24
|
+
lineage;
|
|
25
|
+
4. exchange the code for tokens and map the framework
|
|
26
|
+
:class:`~indusagi.llmgateway.credentials.oauth.OAuthTokens` into the app's
|
|
27
|
+
vault-stored :class:`~.contract.OAuthCredentials` shape;
|
|
28
|
+
5. persist through the injected :class:`~.contract.AuthVault` Protocol — the
|
|
29
|
+
vault (the boot layer's disk implementation) stays the single
|
|
30
|
+
``auth.json`` writer.
|
|
31
|
+
|
|
32
|
+
On top of the flow it offers what the credential command needs:
|
|
33
|
+
|
|
34
|
+
- :func:`register_built_in_oauth_providers` — populates the adapter registry
|
|
35
|
+
from the framework's :data:`OAUTH_PROVIDERS` config table. **Explicitly** —
|
|
36
|
+
there is deliberately no import-time registration (the TS module registered
|
|
37
|
+
on import; the port plan forbids import-time side effects). The boot layer
|
|
38
|
+
primes the registry as a stage; tests prime it in a fixture.
|
|
39
|
+
- :func:`list_login_providers` — the merged sign-in directory, every entry
|
|
40
|
+
tagged with whether it authenticates by browser sign-in or by api key.
|
|
41
|
+
- :func:`start_oauth_login` — drives the re-choreographed login flow and
|
|
42
|
+
persists the resulting credentials through the vault.
|
|
43
|
+
- :func:`refresh_oauth_credentials` — the refresh seam (the TS
|
|
44
|
+
``ensureFreshOAuthCredentials`` analogue) the boot vault calls when a stored
|
|
45
|
+
browser token has expired.
|
|
46
|
+
- :func:`open_login_url` — a default-browser launcher
|
|
47
|
+
(:mod:`webbrowser`-backed; the TS per-platform ``launchAttempts`` spawn
|
|
48
|
+
table collapses into the stdlib, which encodes the same fallbacks).
|
|
49
|
+
|
|
50
|
+
Adaptation notes
|
|
51
|
+
----------------
|
|
52
|
+
- The framework's :data:`OAUTH_PROVIDERS` table carries **two** providers —
|
|
53
|
+
``anthropic`` and ``openai`` — and its ``ProviderId`` literal does not admit
|
|
54
|
+
a ``github-copilot`` id, so a Copilot config cannot live framework-side
|
|
55
|
+
without widening that type. The TS build registered three provider objects
|
|
56
|
+
(``anthropic``, ``openai-codex``, ``github-copilot``); the first two map to
|
|
57
|
+
the framework's authorization-code (PKCE) configs, but **GitHub Copilot uses
|
|
58
|
+
the OAuth device-authorization grant** (RFC 8628) — a flow the framework has
|
|
59
|
+
no primitive for. So the Copilot provider is registered **app-side** here,
|
|
60
|
+
with its own device-flow login/refresh driver ported from the TS
|
|
61
|
+
``githubCopilotOAuthProvider``. The built-in set is therefore the two
|
|
62
|
+
framework configs *plus* the app's ``github-copilot`` adapter, and the tests
|
|
63
|
+
pin *that* set.
|
|
64
|
+
- A provider's login choreography is its adapter's
|
|
65
|
+
:attr:`OAuthProviderAdapter.driver`. The default driver is the
|
|
66
|
+
authorization-code (PKCE) flow the two framework configs run; Copilot
|
|
67
|
+
supplies the device-flow driver. ``start_oauth_login`` always drives through
|
|
68
|
+
the adapter's driver, so adding a new flow is a per-adapter change, not a
|
|
69
|
+
fork in the command path.
|
|
70
|
+
- ``callbacks.on_auth`` may return an awaitable; the driver awaits it. (TS
|
|
71
|
+
fired the browser-open as a dangling ``void`` promise; awaiting keeps the
|
|
72
|
+
print ordering deterministic without changing the callback shape tests
|
|
73
|
+
drive.)
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
from __future__ import annotations
|
|
77
|
+
|
|
78
|
+
import asyncio
|
|
79
|
+
import inspect
|
|
80
|
+
import os
|
|
81
|
+
import time
|
|
82
|
+
import uuid
|
|
83
|
+
from collections.abc import Awaitable, Callable
|
|
84
|
+
from dataclasses import dataclass
|
|
85
|
+
from typing import Any, Final, Literal, TypeAlias
|
|
86
|
+
from urllib.parse import parse_qs, urlsplit
|
|
87
|
+
|
|
88
|
+
from indusagi.llmgateway.credentials.oauth import (
|
|
89
|
+
OAUTH_PROVIDERS,
|
|
90
|
+
AuthUrlInputs,
|
|
91
|
+
OAuthConfig,
|
|
92
|
+
build_auth_url,
|
|
93
|
+
exchange_code,
|
|
94
|
+
refresh_token,
|
|
95
|
+
)
|
|
96
|
+
from indusagi.llmgateway.credentials.pkce import create_pkce_pair
|
|
97
|
+
|
|
98
|
+
from .contract import AuthVault, OAuthCredentials
|
|
99
|
+
|
|
100
|
+
__all__ = [
|
|
101
|
+
"AuthKind",
|
|
102
|
+
"LoginDriver",
|
|
103
|
+
"LoginProvider",
|
|
104
|
+
"OAuthAuthorization",
|
|
105
|
+
"OAuthLoginCallbacks",
|
|
106
|
+
"OAuthLoginError",
|
|
107
|
+
"OAuthLoginResult",
|
|
108
|
+
"OAuthPrompt",
|
|
109
|
+
"OAuthProviderAdapter",
|
|
110
|
+
"RefreshDriver",
|
|
111
|
+
"get_oauth_provider",
|
|
112
|
+
"get_oauth_providers",
|
|
113
|
+
"github_copilot_adapter",
|
|
114
|
+
"list_login_providers",
|
|
115
|
+
"open_login_url",
|
|
116
|
+
"refresh_oauth_credentials",
|
|
117
|
+
"register_built_in_oauth_providers",
|
|
118
|
+
"register_oauth_provider",
|
|
119
|
+
"start_oauth_login",
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Login callbacks (the TS OAuthLoginCallbacks shape, app-owned in the port)
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True, slots=True)
|
|
129
|
+
class OAuthAuthorization:
|
|
130
|
+
"""What ``on_auth`` receives: the consent URL the user must visit, plus
|
|
131
|
+
optional human-facing instructions."""
|
|
132
|
+
|
|
133
|
+
url: str
|
|
134
|
+
instructions: str | None = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True, slots=True)
|
|
138
|
+
class OAuthPrompt:
|
|
139
|
+
"""What ``on_prompt`` receives: the question the flow needs answered (the
|
|
140
|
+
pasted authorization code)."""
|
|
141
|
+
|
|
142
|
+
message: str
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True, slots=True)
|
|
146
|
+
class OAuthLoginCallbacks:
|
|
147
|
+
"""The interactive seams the login flow drives. ``on_auth`` surfaces the
|
|
148
|
+
consent URL (and may return an awaitable, which is awaited); ``on_prompt``
|
|
149
|
+
reads one line of user input; ``on_progress`` relays status lines."""
|
|
150
|
+
|
|
151
|
+
on_auth: Callable[[OAuthAuthorization], None | Awaitable[None]]
|
|
152
|
+
on_prompt: Callable[[OAuthPrompt], Awaitable[str]]
|
|
153
|
+
on_progress: Callable[[str], None] | None = None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class OAuthLoginError(RuntimeError):
|
|
157
|
+
"""A browser sign-in failure (unknown provider, aborted paste, or a
|
|
158
|
+
failed exchange wrapped by the gateway)."""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Provider registry (the adapter's own — the framework has none to prime)
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
#: A provider's login choreography: drive the callbacks and return the
|
|
167
|
+
#: vault-shaped credentials. The default is the authorization-code (PKCE)
|
|
168
|
+
#: flow; a provider whose sign-in differs (GitHub Copilot's device grant)
|
|
169
|
+
#: supplies its own.
|
|
170
|
+
LoginDriver: TypeAlias = Callable[
|
|
171
|
+
["OAuthProviderAdapter", "OAuthLoginCallbacks"], Awaitable[OAuthCredentials]
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
#: A provider's refresh choreography: mint fresh credentials from a stored
|
|
175
|
+
#: refresh token. The default trades a refresh token at the framework token
|
|
176
|
+
#: endpoint; a device-flow provider re-derives a short-lived token instead.
|
|
177
|
+
RefreshDriver: TypeAlias = Callable[
|
|
178
|
+
["OAuthProviderAdapter", OAuthCredentials], Awaitable[OAuthCredentials]
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True, slots=True)
|
|
183
|
+
class OAuthProviderAdapter:
|
|
184
|
+
"""One registered browser-sign-in provider: its stable id, a human name,
|
|
185
|
+
the framework :class:`OAuthConfig` the flow runs over, and the
|
|
186
|
+
login/refresh drivers that choreograph it.
|
|
187
|
+
|
|
188
|
+
``driver`` / ``refresh_driver`` default to ``None``, which selects the
|
|
189
|
+
built-in authorization-code (PKCE) flow the two framework configs use. A
|
|
190
|
+
provider with a different sign-in shape — GitHub Copilot's device grant —
|
|
191
|
+
supplies its own drivers; ``config`` then carries only what that driver
|
|
192
|
+
reads (Copilot reads none of it, so a placeholder config stands in)."""
|
|
193
|
+
|
|
194
|
+
# Stable provider id (the registry key).
|
|
195
|
+
id: str
|
|
196
|
+
# Human-facing provider name (the directory fallback label).
|
|
197
|
+
name: str
|
|
198
|
+
# The framework endpoint + client configuration the default flow drives.
|
|
199
|
+
config: OAuthConfig
|
|
200
|
+
# The login choreography; None selects the default PKCE flow.
|
|
201
|
+
driver: LoginDriver | None = None
|
|
202
|
+
# The refresh choreography; None selects the default token-endpoint refresh.
|
|
203
|
+
refresh_driver: RefreshDriver | None = None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
#: Human labels for the provider ids this app supplies one for. Covers both
|
|
207
|
+
#: the Python framework's config ids and the TS lineage's provider-object ids
|
|
208
|
+
#: so the merged directory reads well whichever the table grows to carry.
|
|
209
|
+
_OAUTH_LABELS: Final[dict[str, str]] = {
|
|
210
|
+
"anthropic": "Anthropic (Claude sign-in)",
|
|
211
|
+
"openai": "OpenAI (ChatGPT sign-in)",
|
|
212
|
+
"openai-codex": "OpenAI (ChatGPT sign-in)",
|
|
213
|
+
"github-copilot": "GitHub Copilot",
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#: The adapter registry. Module-held like the TS framework registry was, but
|
|
217
|
+
#: populated only by the explicit prime below — never at import time.
|
|
218
|
+
_REGISTRY: dict[str, OAuthProviderAdapter] = {}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def register_oauth_provider(provider: OAuthProviderAdapter) -> None:
|
|
222
|
+
"""Register (or replace) one browser-sign-in provider."""
|
|
223
|
+
_REGISTRY[provider.id] = provider
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def get_oauth_provider(provider_id: str) -> OAuthProviderAdapter | None:
|
|
227
|
+
"""Look up a registered provider by id, or ``None``."""
|
|
228
|
+
return _REGISTRY.get(provider_id)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def get_oauth_providers() -> list[OAuthProviderAdapter]:
|
|
232
|
+
"""Every registered provider, in registration order."""
|
|
233
|
+
return list(_REGISTRY.values())
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _reset_oauth_providers_for_tests() -> None:
|
|
237
|
+
"""Test hook: empty the registry so suites stay hermetic."""
|
|
238
|
+
_REGISTRY.clear()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def register_built_in_oauth_providers() -> list[str]:
|
|
242
|
+
"""Register the built-in sign-in providers, idempotently.
|
|
243
|
+
|
|
244
|
+
Walks the framework :data:`OAUTH_PROVIDERS` config table and registers an
|
|
245
|
+
adapter for each authorization-code provider (``anthropic``, ``openai``)
|
|
246
|
+
not already present, then registers the **app-side**
|
|
247
|
+
:func:`github_copilot_adapter` whose device-flow driver has no framework
|
|
248
|
+
config (see the module docstring). Repeated calls leave the registry
|
|
249
|
+
unchanged. Returns the ids that are registered after the call, for callers
|
|
250
|
+
that want to confirm the set.
|
|
251
|
+
|
|
252
|
+
This is the **explicit prime** the boot layer runs as a stage; nothing
|
|
253
|
+
runs it at import time.
|
|
254
|
+
"""
|
|
255
|
+
for provider_id, config in OAUTH_PROVIDERS.items():
|
|
256
|
+
if get_oauth_provider(provider_id) is None:
|
|
257
|
+
register_oauth_provider(
|
|
258
|
+
OAuthProviderAdapter(
|
|
259
|
+
id=str(provider_id),
|
|
260
|
+
name=_OAUTH_LABELS.get(str(provider_id), str(provider_id)),
|
|
261
|
+
config=config,
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
if get_oauth_provider("github-copilot") is None:
|
|
265
|
+
register_oauth_provider(github_copilot_adapter())
|
|
266
|
+
return [provider.id for provider in get_oauth_providers()]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
# Merged sign-in directory
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
#: How a provider authenticates in the merged sign-in directory.
|
|
274
|
+
AuthKind: TypeAlias = Literal["oauth", "apiKey"]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@dataclass(frozen=True, slots=True)
|
|
278
|
+
class LoginProvider:
|
|
279
|
+
"""One row of the merged sign-in directory."""
|
|
280
|
+
|
|
281
|
+
# Stable provider id (adapter registry id or api-key directory id).
|
|
282
|
+
id: str
|
|
283
|
+
# Human-facing label for menus and prompts.
|
|
284
|
+
label: str
|
|
285
|
+
# Whether this entry signs in via the browser or stores an api key.
|
|
286
|
+
auth_kind: AuthKind
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def list_login_providers() -> list[LoginProvider]:
|
|
290
|
+
"""The merged sign-in directory: every registered browser-sign-in provider
|
|
291
|
+
plus every api-key provider the app knows, each tagged with its auth kind.
|
|
292
|
+
|
|
293
|
+
Browser-sign-in providers lead the list (they are the preferred path for
|
|
294
|
+
the providers that support them); an api-key provider that shares an id
|
|
295
|
+
with a registered sign-in provider is still listed, so a user can choose
|
|
296
|
+
either path for the same underlying account.
|
|
297
|
+
"""
|
|
298
|
+
# Imported lazily: credentials.py imports this module at the top level
|
|
299
|
+
# (the same cycle the TS pair had; one side must defer in Python).
|
|
300
|
+
from .credentials import PROVIDER_DIRECTORY
|
|
301
|
+
|
|
302
|
+
oauth_rows = [
|
|
303
|
+
LoginProvider(
|
|
304
|
+
id=provider.id,
|
|
305
|
+
label=_OAUTH_LABELS.get(provider.id, provider.name),
|
|
306
|
+
auth_kind="oauth",
|
|
307
|
+
)
|
|
308
|
+
for provider in get_oauth_providers()
|
|
309
|
+
]
|
|
310
|
+
api_key_rows = [
|
|
311
|
+
LoginProvider(id=entry.id, label=entry.label, auth_kind="apiKey")
|
|
312
|
+
for entry in PROVIDER_DIRECTORY
|
|
313
|
+
]
|
|
314
|
+
return [*oauth_rows, *api_key_rows]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
# Browser launcher
|
|
319
|
+
# ---------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _is_web_url(url: str) -> bool:
|
|
323
|
+
"""Accept only ``http``/``https`` urls; anything else is refused before
|
|
324
|
+
launch."""
|
|
325
|
+
try:
|
|
326
|
+
split = urlsplit(url)
|
|
327
|
+
except ValueError:
|
|
328
|
+
return False
|
|
329
|
+
return split.scheme in ("http", "https") and bool(split.netloc)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
async def open_login_url(url: str) -> bool:
|
|
333
|
+
"""Open a sign-in url in the user's default browser.
|
|
334
|
+
|
|
335
|
+
Refuses anything that is not an ``http``/``https`` url and resolves
|
|
336
|
+
``False``, so a provider that hands back a non-web redirect never spawns a
|
|
337
|
+
process. The launch itself goes through :func:`webbrowser.open`, which
|
|
338
|
+
encodes the per-platform default-browser fallbacks the TS spawn table
|
|
339
|
+
spelled out by hand. Resolves ``True`` if a browser was launched,
|
|
340
|
+
``False`` otherwise.
|
|
341
|
+
"""
|
|
342
|
+
if not _is_web_url(url):
|
|
343
|
+
return False
|
|
344
|
+
import asyncio
|
|
345
|
+
import webbrowser
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
return bool(await asyncio.to_thread(webbrowser.open, url))
|
|
349
|
+
except Exception:
|
|
350
|
+
return False
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# ---------------------------------------------------------------------------
|
|
354
|
+
# Login flow
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@dataclass(frozen=True, slots=True)
|
|
359
|
+
class OAuthLoginResult:
|
|
360
|
+
"""The outcome of :func:`start_oauth_login`: the provider and account the
|
|
361
|
+
credentials were stored under, and whether this account became the
|
|
362
|
+
default."""
|
|
363
|
+
|
|
364
|
+
# The provider id the sign-in completed for.
|
|
365
|
+
provider: str
|
|
366
|
+
# The account name the credentials were stored under.
|
|
367
|
+
account: str
|
|
368
|
+
# Whether the new account was stored as the provider default.
|
|
369
|
+
made_default: bool
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _extract_code(pasted: str) -> str:
|
|
373
|
+
"""Pull an authorization code out of whatever the user pasted. They may
|
|
374
|
+
paste the bare code, or the whole redirect URL — in the latter case the
|
|
375
|
+
``code`` query parameter is read. A ``code#state`` fragment (some
|
|
376
|
+
providers append the state) is trimmed to the code half."""
|
|
377
|
+
if len(pasted) == 0:
|
|
378
|
+
return ""
|
|
379
|
+
if "://" in pasted or pasted.startswith("http"):
|
|
380
|
+
try:
|
|
381
|
+
split = urlsplit(pasted)
|
|
382
|
+
except ValueError:
|
|
383
|
+
split = None
|
|
384
|
+
if split is not None and split.scheme and (split.netloc or split.path):
|
|
385
|
+
from_query = parse_qs(split.query).get("code", [])
|
|
386
|
+
if from_query and from_query[0]:
|
|
387
|
+
return from_query[0]
|
|
388
|
+
hash_index = pasted.find("#")
|
|
389
|
+
return pasted if hash_index == -1 else pasted[:hash_index]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _to_credentials(
|
|
393
|
+
tokens: object, *, prior_refresh: str | None = None
|
|
394
|
+
) -> OAuthCredentials:
|
|
395
|
+
"""Map a framework ``OAuthTokens`` record into the vault's
|
|
396
|
+
:class:`~.contract.OAuthCredentials` shape, carrying a prior refresh token
|
|
397
|
+
forward when the server did not rotate one."""
|
|
398
|
+
access = getattr(tokens, "access_token", "")
|
|
399
|
+
refresh = getattr(tokens, "refresh_token", None)
|
|
400
|
+
expires = getattr(tokens, "expires_at", None)
|
|
401
|
+
return OAuthCredentials(
|
|
402
|
+
access=str(access),
|
|
403
|
+
refresh=refresh if isinstance(refresh, str) else prior_refresh,
|
|
404
|
+
expires=expires if isinstance(expires, int) else None,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
async def _drive_login(
|
|
409
|
+
adapter: OAuthProviderAdapter,
|
|
410
|
+
callbacks: OAuthLoginCallbacks,
|
|
411
|
+
) -> OAuthCredentials:
|
|
412
|
+
"""The re-choreographed ``provider.login(callbacks)``: PKCE + auth URL →
|
|
413
|
+
``on_auth`` → pasted code via ``on_prompt`` → ``exchange_code`` →
|
|
414
|
+
credentials. Raises :class:`OAuthLoginError` when the user pastes nothing;
|
|
415
|
+
a failed exchange propagates the gateway error."""
|
|
416
|
+
pkce = create_pkce_pair()
|
|
417
|
+
state = str(uuid.uuid4())
|
|
418
|
+
url = build_auth_url(adapter.config, AuthUrlInputs(state=state, pkce=pkce))
|
|
419
|
+
|
|
420
|
+
handled = callbacks.on_auth(
|
|
421
|
+
OAuthAuthorization(
|
|
422
|
+
url=url,
|
|
423
|
+
instructions=(
|
|
424
|
+
"Approve access, then paste the authorization code "
|
|
425
|
+
"(or the full redirect URL) back here."
|
|
426
|
+
),
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
if inspect.isawaitable(handled):
|
|
430
|
+
await handled
|
|
431
|
+
|
|
432
|
+
pasted = (
|
|
433
|
+
await callbacks.on_prompt(OAuthPrompt(message="Paste the authorization code:"))
|
|
434
|
+
).strip()
|
|
435
|
+
code = _extract_code(pasted)
|
|
436
|
+
if len(code) == 0:
|
|
437
|
+
raise OAuthLoginError("No authorization code was entered.")
|
|
438
|
+
|
|
439
|
+
if callbacks.on_progress is not None:
|
|
440
|
+
callbacks.on_progress("Exchanging the authorization code for tokens...")
|
|
441
|
+
tokens = await exchange_code(adapter.config, code, pkce.verifier)
|
|
442
|
+
return _to_credentials(tokens)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
async def start_oauth_login(
|
|
446
|
+
provider_id: str,
|
|
447
|
+
callbacks: OAuthLoginCallbacks,
|
|
448
|
+
vault: AuthVault,
|
|
449
|
+
account: str = "default",
|
|
450
|
+
) -> OAuthLoginResult:
|
|
451
|
+
"""Drive a provider's browser sign-in and persist the result.
|
|
452
|
+
|
|
453
|
+
Looks the provider up in the (primed) adapter registry, runs the
|
|
454
|
+
re-choreographed login flow with the supplied callbacks, and writes the
|
|
455
|
+
returned credentials into the vault under ``account``. The first account
|
|
456
|
+
stored for a provider becomes its default. Raises when the provider id is
|
|
457
|
+
not registered or the login flow fails.
|
|
458
|
+
|
|
459
|
+
:param provider_id: the registered sign-in provider id
|
|
460
|
+
:param callbacks: the login callbacks (url surface, prompt, progress)
|
|
461
|
+
:param vault: the credential store the result is persisted through
|
|
462
|
+
:param account: the account name to store under (defaults to "default")
|
|
463
|
+
"""
|
|
464
|
+
adapter = get_oauth_provider(provider_id)
|
|
465
|
+
if adapter is None:
|
|
466
|
+
raise OAuthLoginError(
|
|
467
|
+
f'No browser sign-in is available for "{provider_id}".'
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
# Each adapter chooses its own login choreography; the default is the
|
|
471
|
+
# authorization-code (PKCE) flow.
|
|
472
|
+
drive = adapter.driver if adapter.driver is not None else _drive_login
|
|
473
|
+
credentials = await drive(adapter, callbacks)
|
|
474
|
+
|
|
475
|
+
existing = await vault.list_accounts(provider_id)
|
|
476
|
+
made_default = len(existing) == 0
|
|
477
|
+
await vault.put_oauth(provider_id, account, credentials, made_default)
|
|
478
|
+
|
|
479
|
+
return OAuthLoginResult(
|
|
480
|
+
provider=provider_id, account=account, made_default=made_default
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# ---------------------------------------------------------------------------
|
|
485
|
+
# Refresh seam (the TS ensureFreshOAuthCredentials analogue)
|
|
486
|
+
# ---------------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
async def refresh_oauth_credentials(
|
|
490
|
+
provider_id: str,
|
|
491
|
+
credentials: OAuthCredentials,
|
|
492
|
+
) -> OAuthCredentials:
|
|
493
|
+
"""Mint fresh credentials from a stored refresh token.
|
|
494
|
+
|
|
495
|
+
The seam the boot layer's disk vault calls from ``read_usable_key`` when a
|
|
496
|
+
stored browser token has expired (it persists whatever this returns).
|
|
497
|
+
Carries the prior refresh token forward when the server does not rotate
|
|
498
|
+
one. Raises :class:`OAuthLoginError` when the provider is unknown or the
|
|
499
|
+
record has no refresh token; a failed wire refresh propagates the gateway
|
|
500
|
+
error.
|
|
501
|
+
"""
|
|
502
|
+
adapter = get_oauth_provider(provider_id)
|
|
503
|
+
if adapter is None:
|
|
504
|
+
raise OAuthLoginError(
|
|
505
|
+
f'No browser sign-in is available for "{provider_id}".'
|
|
506
|
+
)
|
|
507
|
+
if credentials.refresh is None:
|
|
508
|
+
raise OAuthLoginError(
|
|
509
|
+
f"Stored {provider_id} credentials have no refresh token."
|
|
510
|
+
)
|
|
511
|
+
# A provider with a custom login flow (device grant) refreshes its own way.
|
|
512
|
+
if adapter.refresh_driver is not None:
|
|
513
|
+
return await adapter.refresh_driver(adapter, credentials)
|
|
514
|
+
tokens = await refresh_token(adapter.config, credentials.refresh)
|
|
515
|
+
return _to_credentials(tokens, prior_refresh=credentials.refresh)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# ---------------------------------------------------------------------------
|
|
519
|
+
# GitHub Copilot — the OAuth device-authorization grant (RFC 8628)
|
|
520
|
+
# ---------------------------------------------------------------------------
|
|
521
|
+
#
|
|
522
|
+
# Ported from the TS ``githubCopilotOAuthProvider``
|
|
523
|
+
# (indus-rebuild/src/facade/ml/kit/auth/github-copilot.ts). Copilot signs in
|
|
524
|
+
# with the *device* grant, not the authorization-code flow the framework
|
|
525
|
+
# configs use: the CLI shows the user a verification URL and a short code, the
|
|
526
|
+
# user enters the code in a browser, and the CLI polls GitHub until the grant
|
|
527
|
+
# is approved. The GitHub access token is then traded at Copilot's token
|
|
528
|
+
# endpoint for the short-lived bearer the model API consumes — that bearer is
|
|
529
|
+
# the stored ``access`` and the GitHub token is the stored ``refresh``.
|
|
530
|
+
|
|
531
|
+
#: OAuth client id GitHub recognises for the device flow. A real deployment
|
|
532
|
+
#: registers its own GitHub OAuth app and exports its id; the placeholder
|
|
533
|
+
#: mirrors the TS default (the flow runs but GitHub rejects an unregistered
|
|
534
|
+
#: client, exactly as in the TS build).
|
|
535
|
+
_COPILOT_CLIENT_ID: Final[str] = os.environ.get(
|
|
536
|
+
"INDUSAGI_GITHUB_COPILOT_CLIENT_ID", "<UNREGISTERED-COPILOT-APP>"
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
#: Headers GitHub's Copilot backend expects from a recognised editor
|
|
540
|
+
#: integration (identical to the TS request headers).
|
|
541
|
+
_COPILOT_HEADERS: Final[dict[str, str]] = {
|
|
542
|
+
"User-Agent": "GitHubCopilotChat/0.35.0",
|
|
543
|
+
"Editor-Version": "vscode/1.107.0",
|
|
544
|
+
"Editor-Plugin-Version": "copilot-chat/0.35.0",
|
|
545
|
+
"Copilot-Integration-Id": "vscode-chat",
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
#: The scope the device-code request asks for (TS ``"read:user"``).
|
|
549
|
+
_COPILOT_SCOPE: Final[str] = "read:user"
|
|
550
|
+
|
|
551
|
+
#: A placeholder framework config so the adapter dataclass stays well-formed;
|
|
552
|
+
#: the device-flow driver reads none of it (GitHub's endpoints are derived
|
|
553
|
+
#: from the host below, not this config).
|
|
554
|
+
_COPILOT_CONFIG: Final[OAuthConfig] = OAuthConfig(
|
|
555
|
+
provider="anthropic", # type: ignore[arg-type] # placeholder; unused by the device driver
|
|
556
|
+
authorize_url="https://github.com/login/device",
|
|
557
|
+
token_url="https://github.com/login/oauth/access_token",
|
|
558
|
+
client_id=_COPILOT_CLIENT_ID,
|
|
559
|
+
redirect_uri="",
|
|
560
|
+
scopes=(_COPILOT_SCOPE,),
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _copilot_endpoints(domain: str) -> tuple[str, str, str]:
|
|
565
|
+
"""The three GitHub endpoints the device flow uses for a host (TS
|
|
566
|
+
``buildEndpoints``): device-code, access-token, copilot-token."""
|
|
567
|
+
return (
|
|
568
|
+
f"https://{domain}/login/device/code",
|
|
569
|
+
f"https://{domain}/login/oauth/access_token",
|
|
570
|
+
f"https://api.{domain}/copilot_internal/v2/token",
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
async def _copilot_post_json(
|
|
575
|
+
url: str, body: dict[str, Any], *, extra_headers: dict[str, str] | None = None
|
|
576
|
+
) -> dict[str, Any]:
|
|
577
|
+
"""POST a JSON body and return the parsed JSON object, raising
|
|
578
|
+
:class:`OAuthLoginError` on a non-2xx status (TS ``request``)."""
|
|
579
|
+
import httpx
|
|
580
|
+
|
|
581
|
+
headers = {
|
|
582
|
+
"Accept": "application/json",
|
|
583
|
+
"Content-Type": "application/json",
|
|
584
|
+
"User-Agent": "GitHubCopilotChat/0.35.0",
|
|
585
|
+
}
|
|
586
|
+
if extra_headers is not None:
|
|
587
|
+
headers.update(extra_headers)
|
|
588
|
+
async with httpx.AsyncClient(timeout=None) as client:
|
|
589
|
+
response = await client.post(url, json=body, headers=headers)
|
|
590
|
+
if not response.is_success:
|
|
591
|
+
detail = ""
|
|
592
|
+
try:
|
|
593
|
+
detail = response.text
|
|
594
|
+
except Exception:
|
|
595
|
+
detail = ""
|
|
596
|
+
raise OAuthLoginError(
|
|
597
|
+
f"GitHub returned {response.status_code}{f': {detail}' if detail else ''}."
|
|
598
|
+
)
|
|
599
|
+
parsed = response.json()
|
|
600
|
+
if not isinstance(parsed, dict):
|
|
601
|
+
raise OAuthLoginError("GitHub returned an unexpected (non-object) response.")
|
|
602
|
+
return parsed
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
async def _copilot_get_json(
|
|
606
|
+
url: str, *, headers: dict[str, str]
|
|
607
|
+
) -> dict[str, Any]:
|
|
608
|
+
"""GET a JSON object from the Copilot token endpoint, raising on a non-2xx
|
|
609
|
+
status (TS ``request`` over a GET)."""
|
|
610
|
+
import httpx
|
|
611
|
+
|
|
612
|
+
async with httpx.AsyncClient(timeout=None) as client:
|
|
613
|
+
response = await client.get(url, headers=headers)
|
|
614
|
+
if not response.is_success:
|
|
615
|
+
detail = ""
|
|
616
|
+
try:
|
|
617
|
+
detail = response.text
|
|
618
|
+
except Exception:
|
|
619
|
+
detail = ""
|
|
620
|
+
raise OAuthLoginError(
|
|
621
|
+
f"Copilot token endpoint returned {response.status_code}"
|
|
622
|
+
f"{f': {detail}' if detail else ''}."
|
|
623
|
+
)
|
|
624
|
+
parsed = response.json()
|
|
625
|
+
if not isinstance(parsed, dict):
|
|
626
|
+
raise OAuthLoginError("Copilot token endpoint returned a non-object response.")
|
|
627
|
+
return parsed
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
async def _copilot_poll_access_token(
|
|
631
|
+
domain: str, device_code: str, interval_seconds: float, expires_in: float
|
|
632
|
+
) -> str:
|
|
633
|
+
"""Poll GitHub for the device-grant access token until it is granted,
|
|
634
|
+
rejected, or the deadline passes (TS ``pollAccessToken``). Honours the
|
|
635
|
+
``authorization_pending`` / ``slow_down`` back-off signals."""
|
|
636
|
+
_, access_token_url, _ = _copilot_endpoints(domain)
|
|
637
|
+
deadline = time.time() + expires_in
|
|
638
|
+
interval = max(1.0, float(interval_seconds))
|
|
639
|
+
|
|
640
|
+
while time.time() < deadline:
|
|
641
|
+
raw = await _copilot_post_json(
|
|
642
|
+
access_token_url,
|
|
643
|
+
{
|
|
644
|
+
"client_id": _COPILOT_CLIENT_ID,
|
|
645
|
+
"device_code": device_code,
|
|
646
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
647
|
+
},
|
|
648
|
+
)
|
|
649
|
+
access = raw.get("access_token")
|
|
650
|
+
if isinstance(access, str) and access:
|
|
651
|
+
return access
|
|
652
|
+
error = raw.get("error")
|
|
653
|
+
if isinstance(error, str):
|
|
654
|
+
if error == "authorization_pending":
|
|
655
|
+
await asyncio.sleep(interval)
|
|
656
|
+
continue
|
|
657
|
+
if error == "slow_down":
|
|
658
|
+
interval += 5.0
|
|
659
|
+
await asyncio.sleep(interval)
|
|
660
|
+
continue
|
|
661
|
+
raise OAuthLoginError(
|
|
662
|
+
f"The device-authorization grant was rejected ({error})."
|
|
663
|
+
)
|
|
664
|
+
await asyncio.sleep(interval)
|
|
665
|
+
|
|
666
|
+
raise OAuthLoginError(
|
|
667
|
+
"Timed out waiting for the device authorization to be approved."
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
async def _copilot_token_from_github(
|
|
672
|
+
github_access_token: str, domain: str
|
|
673
|
+
) -> OAuthCredentials:
|
|
674
|
+
"""Trade a GitHub access token for a short-lived Copilot bearer (TS
|
|
675
|
+
``refreshToken``). The Copilot bearer is the stored ``access``; the GitHub
|
|
676
|
+
token is kept as ``refresh`` so it can be re-traded when the bearer
|
|
677
|
+
expires."""
|
|
678
|
+
_, _, copilot_token_url = _copilot_endpoints(domain)
|
|
679
|
+
raw = await _copilot_get_json(
|
|
680
|
+
copilot_token_url,
|
|
681
|
+
headers={
|
|
682
|
+
"Accept": "application/json",
|
|
683
|
+
"Authorization": f"Bearer {github_access_token}",
|
|
684
|
+
**_COPILOT_HEADERS,
|
|
685
|
+
},
|
|
686
|
+
)
|
|
687
|
+
token = raw.get("token")
|
|
688
|
+
expires_at = raw.get("expires_at")
|
|
689
|
+
if not isinstance(token, str) or not isinstance(expires_at, (int, float)):
|
|
690
|
+
raise OAuthLoginError(
|
|
691
|
+
"Copilot token response was missing or had malformed fields."
|
|
692
|
+
)
|
|
693
|
+
# TS subtracts a 5-minute safety margin and stores epoch-ms.
|
|
694
|
+
expires_ms = int(expires_at) * 1000 - 5 * 60 * 1000
|
|
695
|
+
return OAuthCredentials(
|
|
696
|
+
access=token, refresh=github_access_token, expires=expires_ms
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _normalize_copilot_domain(raw: str) -> str | None:
|
|
701
|
+
"""Reduce a user-entered GitHub Enterprise host to a bare hostname, or
|
|
702
|
+
``None`` when unusable (TS ``normalizeDomain``)."""
|
|
703
|
+
cleaned = raw.strip()
|
|
704
|
+
if not cleaned:
|
|
705
|
+
return None
|
|
706
|
+
with_scheme = cleaned if cleaned.startswith("http") else f"https://{cleaned}"
|
|
707
|
+
try:
|
|
708
|
+
host = urlsplit(with_scheme).hostname
|
|
709
|
+
except ValueError:
|
|
710
|
+
return None
|
|
711
|
+
return host or None
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
async def _drive_github_copilot_login(
|
|
715
|
+
adapter: OAuthProviderAdapter,
|
|
716
|
+
callbacks: OAuthLoginCallbacks,
|
|
717
|
+
) -> OAuthCredentials:
|
|
718
|
+
"""The GitHub Copilot device-flow login (TS ``CopilotAuthFlow.login``).
|
|
719
|
+
|
|
720
|
+
Prompts for an optional Enterprise host (blank → ``github.com``), starts
|
|
721
|
+
the device-authorization grant, surfaces the verification URL and user
|
|
722
|
+
code through ``on_auth``, polls GitHub until the grant is approved, and
|
|
723
|
+
trades the resulting GitHub token for a Copilot bearer."""
|
|
724
|
+
del adapter # the device flow derives its endpoints from the host below
|
|
725
|
+
|
|
726
|
+
entered = await callbacks.on_prompt(
|
|
727
|
+
OAuthPrompt(
|
|
728
|
+
message=(
|
|
729
|
+
"Your GitHub Enterprise host (leave empty to use github.com):"
|
|
730
|
+
)
|
|
731
|
+
)
|
|
732
|
+
)
|
|
733
|
+
trimmed = entered.strip()
|
|
734
|
+
enterprise = _normalize_copilot_domain(trimmed) if trimmed else None
|
|
735
|
+
if trimmed and enterprise is None:
|
|
736
|
+
raise OAuthLoginError("That GitHub Enterprise host could not be parsed.")
|
|
737
|
+
domain = enterprise if enterprise is not None else "github.com"
|
|
738
|
+
|
|
739
|
+
device_code_url, _, _ = _copilot_endpoints(domain)
|
|
740
|
+
init = await _copilot_post_json(
|
|
741
|
+
device_code_url,
|
|
742
|
+
{"client_id": _COPILOT_CLIENT_ID, "scope": _COPILOT_SCOPE},
|
|
743
|
+
)
|
|
744
|
+
device_code = init.get("device_code")
|
|
745
|
+
user_code = init.get("user_code")
|
|
746
|
+
verification_uri = init.get("verification_uri")
|
|
747
|
+
interval = init.get("interval")
|
|
748
|
+
expires_in = init.get("expires_in")
|
|
749
|
+
if (
|
|
750
|
+
not isinstance(device_code, str)
|
|
751
|
+
or not isinstance(user_code, str)
|
|
752
|
+
or not isinstance(verification_uri, str)
|
|
753
|
+
or not isinstance(interval, (int, float))
|
|
754
|
+
or not isinstance(expires_in, (int, float))
|
|
755
|
+
):
|
|
756
|
+
raise OAuthLoginError(
|
|
757
|
+
"Device authorization response was missing or had malformed fields."
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
handled = callbacks.on_auth(
|
|
761
|
+
OAuthAuthorization(
|
|
762
|
+
url=verification_uri,
|
|
763
|
+
instructions=f"Type this code when prompted: {user_code}",
|
|
764
|
+
)
|
|
765
|
+
)
|
|
766
|
+
if inspect.isawaitable(handled):
|
|
767
|
+
await handled
|
|
768
|
+
|
|
769
|
+
if callbacks.on_progress is not None:
|
|
770
|
+
callbacks.on_progress("Waiting for the device authorization to be approved...")
|
|
771
|
+
|
|
772
|
+
github_access_token = await _copilot_poll_access_token(
|
|
773
|
+
domain, device_code, float(interval), float(expires_in)
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
if callbacks.on_progress is not None:
|
|
777
|
+
callbacks.on_progress("Exchanging the GitHub token for a Copilot token...")
|
|
778
|
+
return await _copilot_token_from_github(github_access_token, domain)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
async def _refresh_github_copilot(
|
|
782
|
+
adapter: OAuthProviderAdapter,
|
|
783
|
+
credentials: OAuthCredentials,
|
|
784
|
+
) -> OAuthCredentials:
|
|
785
|
+
"""Re-derive a Copilot bearer from the stored GitHub token (TS
|
|
786
|
+
``githubCopilotOAuthProvider.refreshToken``). The stored ``refresh`` is the
|
|
787
|
+
GitHub access token; it is re-traded against ``github.com`` (the default
|
|
788
|
+
host)."""
|
|
789
|
+
del adapter
|
|
790
|
+
if credentials.refresh is None: # pragma: no cover - guarded by the caller
|
|
791
|
+
raise OAuthLoginError("Stored github-copilot credentials have no refresh token.")
|
|
792
|
+
return await _copilot_token_from_github(credentials.refresh, "github.com")
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def github_copilot_adapter() -> OAuthProviderAdapter:
|
|
796
|
+
"""Build the app-side GitHub Copilot sign-in adapter, wired to the
|
|
797
|
+
device-flow login and refresh drivers. Registered by
|
|
798
|
+
:func:`register_built_in_oauth_providers` so ``pindus signin --provider
|
|
799
|
+
github-copilot`` drives the device grant — the framework has no
|
|
800
|
+
``github-copilot`` config or device-code primitive (see the module
|
|
801
|
+
docstring), so this provider is owned entirely here."""
|
|
802
|
+
return OAuthProviderAdapter(
|
|
803
|
+
id="github-copilot",
|
|
804
|
+
name=_OAUTH_LABELS["github-copilot"],
|
|
805
|
+
config=_COPILOT_CONFIG,
|
|
806
|
+
driver=_drive_github_copilot_login,
|
|
807
|
+
refresh_driver=_refresh_github_copilot,
|
|
808
|
+
)
|