gax-cli 0.4.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.
- gax/__init__.py +3 -0
- gax/adapters/__init__.py +3 -0
- gax/adapters/base.py +25 -0
- gax/adapters/exec_adapter.py +261 -0
- gax/adapters/http_adapter.py +33 -0
- gax/adapters/k8s_adapter.py +338 -0
- gax/adapters/mcp_bridge.py +108 -0
- gax/adapters/mock_adapter.py +49 -0
- gax/audit.py +54 -0
- gax/caps.py +91 -0
- gax/cli.py +722 -0
- gax/client.py +58 -0
- gax/compliance.py +71 -0
- gax/config/oauth_providers.yaml +21 -0
- gax/config/policy.rego +20 -0
- gax/config/policy.yaml +59 -0
- gax/daemon.py +187 -0
- gax/envelope.py +64 -0
- gax/executor.py +151 -0
- gax/macaroons_cap.py +80 -0
- gax/manifests/aws.s3.list.yaml +20 -0
- gax/manifests/demo.echo.yaml +20 -0
- gax/manifests/gh.pr.list.yaml +41 -0
- gax/manifests/gh.pr.view.yaml +30 -0
- gax/manifests/jira.issue.get.yaml +18 -0
- gax/manifests/k8s.deployment.scale.yaml +35 -0
- gax/manifests/k8s.pod.delete.yaml +30 -0
- gax/manifests/kubectl.get.pods.yaml +20 -0
- gax/manifests/mcp.github.list_pulls.yaml +29 -0
- gax/manifests/pet_findpetsbystatus.yaml +19 -0
- gax/manifests/pet_getpetbyid.yaml +19 -0
- gax/mcp_client.py +110 -0
- gax/mcp_server.py +299 -0
- gax/oauth.py +184 -0
- gax/onboarding.py +397 -0
- gax/opa_policy.py +46 -0
- gax/openapi_gen.py +96 -0
- gax/otel_audit.py +50 -0
- gax/paths.py +46 -0
- gax/plan.py +151 -0
- gax/policy.py +33 -0
- gax/policy_bundle.py +64 -0
- gax/profiles/github/gh.issue.list.yaml +25 -0
- gax/profiles/github/gh.issue.view.yaml +23 -0
- gax/profiles/github/gh.pr.comment.yaml +30 -0
- gax/profiles/github/gh.pr.merge.yaml +29 -0
- gax/profiles/github/gh.run.list.yaml +22 -0
- gax/profiles/k8s/k8s.deployment.list.yaml +17 -0
- gax/profiles/k8s/k8s.deployment.restart.yaml +25 -0
- gax/profiles/k8s/k8s.namespace.delete.yaml +22 -0
- gax/profiles/k8s/k8s.pod.describe.yaml +22 -0
- gax/profiles/k8s/k8s.pod.logs.yaml +28 -0
- gax/profiles/k8s/k8s.service.list.yaml +17 -0
- gax/profiles.py +143 -0
- gax/projection.py +42 -0
- gax/registry.py +130 -0
- gax/schemas/envelope.v1.json +46 -0
- gax/secrets.py +49 -0
- gax/side_effects.py +67 -0
- gax/spiffe.py +32 -0
- gax/vault.py +69 -0
- gax_cli-0.4.0.dist-info/METADATA +164 -0
- gax_cli-0.4.0.dist-info/RECORD +66 -0
- gax_cli-0.4.0.dist-info/WHEEL +5 -0
- gax_cli-0.4.0.dist-info/entry_points.txt +4 -0
- gax_cli-0.4.0.dist-info/top_level.txt +1 -0
gax/mcp_server.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""
|
|
2
|
+
gax-mcp — expose GAX as an MCP server (stdio JSON-RPC).
|
|
3
|
+
|
|
4
|
+
Any MCP client (Claude Code, Cursor, OpenAI Agent SDK) gets governed shell
|
|
5
|
+
execution with zero GAX-specific integration:
|
|
6
|
+
|
|
7
|
+
claude mcp add gax -- gax-mcp
|
|
8
|
+
|
|
9
|
+
**Why three tools and not one per command.** A naive MCP server publishes one tool
|
|
10
|
+
per capability, so a 43-command registry costs ~44k tokens of schema before the
|
|
11
|
+
first turn. GAX publishes exactly three — `gax_search`, `gax_doc`, `gax_invoke` —
|
|
12
|
+
and keeps the registry behind them. That is the same shape as Anthropic's Tool
|
|
13
|
+
Search / `defer_loading` pattern, so this composes with the platform fix rather
|
|
14
|
+
than competing with it: the client's own tool-search sees a constant-size surface
|
|
15
|
+
no matter how many commands are registered.
|
|
16
|
+
|
|
17
|
+
**The governance boundary is unchanged.** `gax_invoke` calls the same
|
|
18
|
+
`gax.executor.invoke` as the CLI and the daemon, so capability checks, scope
|
|
19
|
+
checks, and policy all run *before* any adapter, and every invoke emits an
|
|
20
|
+
`audit_id`. An MCP client cannot reach a command that is not in the registry, and
|
|
21
|
+
cannot bypass the capability check by phrasing the request differently — the model
|
|
22
|
+
only ever proposes a command name plus args.
|
|
23
|
+
|
|
24
|
+
Transport is stdio JSON-RPC 2.0 written against the stdlib, mirroring
|
|
25
|
+
`gax.mcp_client`, so adding an MCP server adds no dependency.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import sys
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from gax.executor import invoke
|
|
36
|
+
from gax.registry import Registry
|
|
37
|
+
|
|
38
|
+
PROTOCOL_VERSION = "2024-11-05"
|
|
39
|
+
SERVER_NAME = "gax"
|
|
40
|
+
SERVER_VERSION = "0.4"
|
|
41
|
+
|
|
42
|
+
# JSON-RPC 2.0 reserved codes.
|
|
43
|
+
PARSE_ERROR = -32700
|
|
44
|
+
INVALID_REQUEST = -32600
|
|
45
|
+
METHOD_NOT_FOUND = -32601
|
|
46
|
+
INTERNAL_ERROR = -32603
|
|
47
|
+
|
|
48
|
+
TOOLS: list[dict[str, Any]] = [
|
|
49
|
+
{
|
|
50
|
+
"name": "gax_search",
|
|
51
|
+
"description": (
|
|
52
|
+
"Search registered GAX commands by keyword. Use this first to find a "
|
|
53
|
+
"command; it returns names and one-line descriptions only, not full "
|
|
54
|
+
"schemas. Only commands returned here can be invoked."
|
|
55
|
+
),
|
|
56
|
+
"inputSchema": {
|
|
57
|
+
"type": "object",
|
|
58
|
+
"properties": {
|
|
59
|
+
"query": {
|
|
60
|
+
"type": "string",
|
|
61
|
+
"description": "Keywords, e.g. 'pull requests' or 'pods'.",
|
|
62
|
+
},
|
|
63
|
+
"limit": {
|
|
64
|
+
"type": "integer",
|
|
65
|
+
"description": "Max results (default 5).",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
"required": ["query"],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"name": "gax_doc",
|
|
73
|
+
"description": (
|
|
74
|
+
"Get arguments, required scopes, and side effects for one GAX command. "
|
|
75
|
+
"Call this after gax_search and before gax_invoke."
|
|
76
|
+
),
|
|
77
|
+
"inputSchema": {
|
|
78
|
+
"type": "object",
|
|
79
|
+
"properties": {
|
|
80
|
+
"command": {
|
|
81
|
+
"type": "string",
|
|
82
|
+
"description": "Command id, e.g. 'gh.pr.list'.",
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"required": ["command"],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"name": "gax_invoke",
|
|
90
|
+
"description": (
|
|
91
|
+
"Invoke a registered GAX command. The request is checked against the "
|
|
92
|
+
"capability token, required scopes, and policy BEFORE any backend runs; "
|
|
93
|
+
"denials return ok=false with an error.kind. Every call returns an "
|
|
94
|
+
"audit_id. Unregistered commands cannot be invoked."
|
|
95
|
+
),
|
|
96
|
+
"inputSchema": {
|
|
97
|
+
"type": "object",
|
|
98
|
+
"properties": {
|
|
99
|
+
"command": {
|
|
100
|
+
"type": "string",
|
|
101
|
+
"description": "Command id, e.g. 'gh.pr.list'.",
|
|
102
|
+
},
|
|
103
|
+
"args": {
|
|
104
|
+
"type": "object",
|
|
105
|
+
"description": "Arguments per gax_doc.",
|
|
106
|
+
},
|
|
107
|
+
"surface": {
|
|
108
|
+
"type": "string",
|
|
109
|
+
"enum": ["model", "human", "full"],
|
|
110
|
+
"description": "Projection; 'model' truncates for the LLM.",
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
"required": ["command"],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class GaxMcpServer:
|
|
120
|
+
"""MCP protocol surface over the GAX registry and executor."""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
registry: Registry | None = None,
|
|
125
|
+
*,
|
|
126
|
+
capability: str | None = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
self._registry = registry or Registry()
|
|
129
|
+
# Read GAX_CAP lazily at call time if not injected, so a capability minted
|
|
130
|
+
# after the server starts is still picked up.
|
|
131
|
+
self._capability = capability
|
|
132
|
+
|
|
133
|
+
# -- tool implementations ---------------------------------------------
|
|
134
|
+
|
|
135
|
+
def _cap(self) -> str | None:
|
|
136
|
+
return self._capability or os.environ.get("GAX_CAP") or None
|
|
137
|
+
|
|
138
|
+
def tool_search(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
139
|
+
query = str(args.get("query", ""))
|
|
140
|
+
limit = int(args.get("limit") or 5)
|
|
141
|
+
hits = self._registry.search(query, limit=limit)
|
|
142
|
+
return {
|
|
143
|
+
"query": query,
|
|
144
|
+
"results": [
|
|
145
|
+
{
|
|
146
|
+
"command": m.command,
|
|
147
|
+
"description": m.description,
|
|
148
|
+
"category": m.category,
|
|
149
|
+
}
|
|
150
|
+
for m in hits
|
|
151
|
+
],
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
def tool_doc(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
155
|
+
command = str(args.get("command", ""))
|
|
156
|
+
doc = self._registry.doc_stub(command)
|
|
157
|
+
if not doc:
|
|
158
|
+
return {
|
|
159
|
+
"error": "not_found",
|
|
160
|
+
"message": f"unknown command: {command}",
|
|
161
|
+
"hint": "use gax_search to list registered commands",
|
|
162
|
+
}
|
|
163
|
+
return doc
|
|
164
|
+
|
|
165
|
+
def tool_invoke(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
166
|
+
command = str(args.get("command", ""))
|
|
167
|
+
call_args = dict(args.get("args") or {})
|
|
168
|
+
surface = str(args.get("surface") or "model")
|
|
169
|
+
envelope, _exit_code = invoke(
|
|
170
|
+
self._registry,
|
|
171
|
+
command=command,
|
|
172
|
+
args=call_args,
|
|
173
|
+
surface=surface,
|
|
174
|
+
capability=self._cap(),
|
|
175
|
+
)
|
|
176
|
+
return envelope
|
|
177
|
+
|
|
178
|
+
def call_tool(self, name: str, args: dict[str, Any]) -> tuple[dict[str, Any], bool]:
|
|
179
|
+
"""Return (payload, is_error). is_error drives MCP's isError flag."""
|
|
180
|
+
if name == "gax_search":
|
|
181
|
+
return self.tool_search(args), False
|
|
182
|
+
if name == "gax_doc":
|
|
183
|
+
result = self.tool_doc(args)
|
|
184
|
+
return result, bool(result.get("error"))
|
|
185
|
+
if name == "gax_invoke":
|
|
186
|
+
envelope = self.tool_invoke(args)
|
|
187
|
+
# A policy denial is a legitimate protocol-level result, but flagging it
|
|
188
|
+
# as isError makes clients surface it instead of treating it as data.
|
|
189
|
+
return envelope, not bool(envelope.get("ok"))
|
|
190
|
+
raise ValueError(f"unknown tool: {name}")
|
|
191
|
+
|
|
192
|
+
# -- JSON-RPC dispatch -------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def handle(self, req: dict[str, Any]) -> dict[str, Any] | None:
|
|
195
|
+
"""Handle one request. Returns None for notifications (no id)."""
|
|
196
|
+
method = req.get("method")
|
|
197
|
+
rid = req.get("id")
|
|
198
|
+
params = req.get("params") or {}
|
|
199
|
+
|
|
200
|
+
# Notifications carry no id and must never get a response.
|
|
201
|
+
if rid is None:
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
if method == "initialize":
|
|
205
|
+
return _result(
|
|
206
|
+
rid,
|
|
207
|
+
{
|
|
208
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
209
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
210
|
+
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
|
|
211
|
+
},
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if method == "tools/list":
|
|
215
|
+
return _result(rid, {"tools": TOOLS})
|
|
216
|
+
|
|
217
|
+
if method == "tools/call":
|
|
218
|
+
name = str(params.get("name", ""))
|
|
219
|
+
args = dict(params.get("arguments") or {})
|
|
220
|
+
try:
|
|
221
|
+
payload, is_error = self.call_tool(name, args)
|
|
222
|
+
except ValueError as e:
|
|
223
|
+
return _error(rid, METHOD_NOT_FOUND, str(e))
|
|
224
|
+
except Exception as e: # adapter blew up — report, don't crash the server
|
|
225
|
+
return _result(
|
|
226
|
+
rid,
|
|
227
|
+
{
|
|
228
|
+
"content": [{"type": "text", "text": json.dumps({"error": str(e)})}],
|
|
229
|
+
"isError": True,
|
|
230
|
+
},
|
|
231
|
+
)
|
|
232
|
+
return _result(
|
|
233
|
+
rid,
|
|
234
|
+
{
|
|
235
|
+
"content": [
|
|
236
|
+
{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}
|
|
237
|
+
],
|
|
238
|
+
"isError": is_error,
|
|
239
|
+
},
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if method == "ping":
|
|
243
|
+
return _result(rid, {})
|
|
244
|
+
|
|
245
|
+
return _error(rid, METHOD_NOT_FOUND, f"unknown method: {method}")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _result(rid: Any, result: dict[str, Any]) -> dict[str, Any]:
|
|
249
|
+
return {"jsonrpc": "2.0", "id": rid, "result": result}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _error(rid: Any, code: int, message: str) -> dict[str, Any]:
|
|
253
|
+
return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def serve(
|
|
257
|
+
stdin: Any = None,
|
|
258
|
+
stdout: Any = None,
|
|
259
|
+
*,
|
|
260
|
+
registry: Registry | None = None,
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Run the stdio loop until EOF."""
|
|
263
|
+
stdin = stdin or sys.stdin
|
|
264
|
+
stdout = stdout or sys.stdout
|
|
265
|
+
server = GaxMcpServer(registry=registry)
|
|
266
|
+
|
|
267
|
+
for line in stdin:
|
|
268
|
+
line = line.strip()
|
|
269
|
+
if not line:
|
|
270
|
+
continue
|
|
271
|
+
try:
|
|
272
|
+
req = json.loads(line)
|
|
273
|
+
except json.JSONDecodeError:
|
|
274
|
+
_write(stdout, _error(None, PARSE_ERROR, "invalid JSON"))
|
|
275
|
+
continue
|
|
276
|
+
if not isinstance(req, dict):
|
|
277
|
+
_write(stdout, _error(None, INVALID_REQUEST, "request must be an object"))
|
|
278
|
+
continue
|
|
279
|
+
try:
|
|
280
|
+
resp = server.handle(req)
|
|
281
|
+
except Exception as e: # never let one bad request kill the session
|
|
282
|
+
_write(stdout, _error(req.get("id"), INTERNAL_ERROR, str(e)))
|
|
283
|
+
continue
|
|
284
|
+
if resp is not None:
|
|
285
|
+
_write(stdout, resp)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _write(stream: Any, msg: dict[str, Any]) -> None:
|
|
289
|
+
stream.write(json.dumps(msg, ensure_ascii=False) + "\n")
|
|
290
|
+
stream.flush()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def main() -> None:
|
|
294
|
+
"""Console entry point: `gax-mcp`."""
|
|
295
|
+
serve()
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
if __name__ == "__main__":
|
|
299
|
+
main()
|
gax/oauth.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from gax.paths import CONFIG_DIR, GAX_HOME, ensure_gax_home
|
|
14
|
+
|
|
15
|
+
PROVIDERS_PATH = CONFIG_DIR / "oauth_providers.yaml"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class OAuthProvider:
|
|
20
|
+
name: str
|
|
21
|
+
device_authorization_url: str
|
|
22
|
+
token_url: str
|
|
23
|
+
client_id_env: str
|
|
24
|
+
scopes: list[str]
|
|
25
|
+
audience: str | None = None
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def client_id(self) -> str:
|
|
29
|
+
val = os.environ.get(self.client_id_env, "").strip()
|
|
30
|
+
if not val:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"Set {self.client_id_env} for OAuth client id (register an OAuth app for provider {self.name})"
|
|
33
|
+
)
|
|
34
|
+
return val
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_providers() -> dict[str, OAuthProvider]:
|
|
38
|
+
path = PROVIDERS_PATH
|
|
39
|
+
if not path.exists():
|
|
40
|
+
return {}
|
|
41
|
+
raw = yaml.safe_load(path.read_text()) or {}
|
|
42
|
+
out: dict[str, OAuthProvider] = {}
|
|
43
|
+
for name, cfg in (raw.get("providers") or {}).items():
|
|
44
|
+
out[name] = OAuthProvider(
|
|
45
|
+
name=name,
|
|
46
|
+
device_authorization_url=str(cfg["device_authorization_url"]),
|
|
47
|
+
token_url=str(cfg["token_url"]),
|
|
48
|
+
client_id_env=str(cfg.get("client_id_env", f"GAX_{name.upper()}_CLIENT_ID")),
|
|
49
|
+
scopes=list(cfg.get("scopes") or []),
|
|
50
|
+
audience=cfg.get("audience"),
|
|
51
|
+
)
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def token_path(tenant: str, provider: str) -> Path:
|
|
56
|
+
ensure_gax_home()
|
|
57
|
+
d = GAX_HOME / "tokens" / tenant
|
|
58
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
return d / f"{provider}.json"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def save_tokens(tenant: str, provider: str, data: dict[str, Any]) -> Path:
|
|
63
|
+
from gax.secrets import save_secret
|
|
64
|
+
|
|
65
|
+
path = token_path(tenant, provider)
|
|
66
|
+
save_secret(tenant, provider, data, file_path=path)
|
|
67
|
+
return path
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_tokens(tenant: str, provider: str) -> dict[str, Any] | None:
|
|
71
|
+
from gax.secrets import load_secret
|
|
72
|
+
|
|
73
|
+
return load_secret(tenant, provider, file_path=token_path(tenant, provider))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def device_flow_login(
|
|
77
|
+
provider: OAuthProvider,
|
|
78
|
+
*,
|
|
79
|
+
tenant: str,
|
|
80
|
+
open_browser: bool = True,
|
|
81
|
+
poll_interval: float | None = None,
|
|
82
|
+
) -> dict[str, Any]:
|
|
83
|
+
"""RFC 8628 device authorization grant."""
|
|
84
|
+
body: dict[str, Any] = {
|
|
85
|
+
"client_id": provider.client_id,
|
|
86
|
+
"scope": " ".join(provider.scopes),
|
|
87
|
+
}
|
|
88
|
+
headers = {"Accept": "application/json"}
|
|
89
|
+
if provider.name == "github":
|
|
90
|
+
headers["Content-Type"] = "application/json"
|
|
91
|
+
r = httpx.post(provider.device_authorization_url, json=body, headers=headers, timeout=30.0)
|
|
92
|
+
else:
|
|
93
|
+
r = httpx.post(provider.device_authorization_url, data=body, headers=headers, timeout=30.0)
|
|
94
|
+
r.raise_for_status()
|
|
95
|
+
device = r.json()
|
|
96
|
+
|
|
97
|
+
verification_uri = device.get("verification_uri_complete") or device.get("verification_uri")
|
|
98
|
+
user_code = device.get("user_code", "")
|
|
99
|
+
device_code = device["device_code"]
|
|
100
|
+
interval = poll_interval or float(device.get("interval", 5))
|
|
101
|
+
expires_in = int(device.get("expires_in", 900))
|
|
102
|
+
|
|
103
|
+
click_echo = __import__("click").echo
|
|
104
|
+
click_echo(f"\nVisit: {verification_uri}")
|
|
105
|
+
if user_code:
|
|
106
|
+
click_echo(f"Code: {user_code}\n")
|
|
107
|
+
if open_browser and verification_uri:
|
|
108
|
+
try:
|
|
109
|
+
import webbrowser
|
|
110
|
+
|
|
111
|
+
webbrowser.open(verification_uri)
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
deadline = time.time() + expires_in
|
|
116
|
+
token_body: dict[str, Any] = {
|
|
117
|
+
"client_id": provider.client_id,
|
|
118
|
+
"device_code": device_code,
|
|
119
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
while time.time() < deadline:
|
|
123
|
+
time.sleep(interval)
|
|
124
|
+
if provider.name == "github":
|
|
125
|
+
tr = httpx.post(
|
|
126
|
+
provider.token_url,
|
|
127
|
+
json=token_body,
|
|
128
|
+
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
129
|
+
timeout=30.0,
|
|
130
|
+
)
|
|
131
|
+
else:
|
|
132
|
+
tr = httpx.post(
|
|
133
|
+
provider.token_url,
|
|
134
|
+
data=token_body,
|
|
135
|
+
headers={"Accept": "application/json"},
|
|
136
|
+
timeout=30.0,
|
|
137
|
+
)
|
|
138
|
+
if tr.status_code == 200:
|
|
139
|
+
tokens = tr.json()
|
|
140
|
+
if "access_token" in tokens:
|
|
141
|
+
tokens["obtained_at"] = time.time()
|
|
142
|
+
tokens["provider"] = provider.name
|
|
143
|
+
tokens["scopes"] = provider.scopes
|
|
144
|
+
save_tokens(tenant, provider.name, tokens)
|
|
145
|
+
return tokens
|
|
146
|
+
err = tr.json() if tr.headers.get("content-type", "").startswith("application/json") else {}
|
|
147
|
+
error = err.get("error", "")
|
|
148
|
+
if error in ("authorization_pending", "slow_down"):
|
|
149
|
+
if error == "slow_down":
|
|
150
|
+
interval = min(interval + 2, 30)
|
|
151
|
+
continue
|
|
152
|
+
if error == "access_denied":
|
|
153
|
+
raise RuntimeError("User denied authorization")
|
|
154
|
+
if error == "expired_token":
|
|
155
|
+
raise RuntimeError("Device code expired; run login again")
|
|
156
|
+
if tr.status_code >= 400 and error not in ("authorization_pending",):
|
|
157
|
+
raise RuntimeError(f"Token poll failed: {tr.text}")
|
|
158
|
+
|
|
159
|
+
raise RuntimeError("Device flow timed out")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def mint_cap_from_oauth(
|
|
163
|
+
tenant: str,
|
|
164
|
+
provider: str,
|
|
165
|
+
*,
|
|
166
|
+
commands: list[str] | None = None,
|
|
167
|
+
ttl_seconds: int = 3600,
|
|
168
|
+
) -> str:
|
|
169
|
+
"""After OAuth login, mint GAX capability JWT scoped to tenant + provider scopes."""
|
|
170
|
+
from gax.caps import mint_capability
|
|
171
|
+
|
|
172
|
+
tokens = load_tokens(tenant, provider)
|
|
173
|
+
if not tokens:
|
|
174
|
+
raise ValueError(f"No tokens for tenant={tenant} provider={provider}; run gax auth login first")
|
|
175
|
+
scopes = tokens.get("scopes") or tokens.get("scope") or []
|
|
176
|
+
if isinstance(scopes, str):
|
|
177
|
+
scopes = [s for s in scopes.replace(",", " ").split() if s]
|
|
178
|
+
return mint_capability(
|
|
179
|
+
tenant_id=tenant,
|
|
180
|
+
subject=tokens.get("subject") or f"oauth:{provider}",
|
|
181
|
+
commands=commands or ["*"],
|
|
182
|
+
scopes=list(scopes) if scopes else ["*"],
|
|
183
|
+
ttl_seconds=ttl_seconds,
|
|
184
|
+
)
|