readmegen-agent 0.2.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.
readmegen/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """ReadMeGen: evidence-based documentation from source code."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("readmegen-agent")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.2.0"
readmegen/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from readmegen.cli import app
2
+
3
+ app()
File without changes
@@ -0,0 +1,356 @@
1
+ """Official loopback OAuth/PKCE integration for local open-source installations.
2
+
3
+ Reference: https://developers.openai.com/siwc/token-sharing-open-source/sign-in
4
+ Tokens stay in the OS credential store or this process's memory.
5
+ """
6
+
7
+ import base64
8
+ import hashlib
9
+ import json
10
+ import secrets
11
+ import threading
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from http.server import BaseHTTPRequestHandler, HTTPServer
15
+ from typing import Any
16
+ from urllib.parse import parse_qs, urlencode, urlsplit
17
+ from uuid import uuid4
18
+
19
+ import httpx
20
+ import jwt
21
+ from filelock import FileLock
22
+
23
+ from readmegen.auth.credentials import CredentialStore
24
+ from readmegen.config import config_dir
25
+ from readmegen.errors import ConfigurationError
26
+ from readmegen.storage import write_json
27
+
28
+ ISSUER = "https://auth.openai.com"
29
+ RESOURCE = "https://api.openai.com/v1"
30
+ TOKEN_ENDPOINT = f"{ISSUER}/api/accounts/oauth/token"
31
+ PLAN_SCOPE = "chatgpt.tokens.use.direct"
32
+ LOGIN_TIMEOUT = 300
33
+
34
+
35
+ @dataclass
36
+ class PendingLogin:
37
+ id: str
38
+ state: str
39
+ nonce: str
40
+ verifier: str
41
+ redirect_uri: str
42
+ profile: dict | None
43
+ persist: bool
44
+ server: HTTPServer
45
+ url: str = ""
46
+ result: dict[str, str] = field(default_factory=dict)
47
+ completed: threading.Event = field(default_factory=threading.Event)
48
+ expires_at: float = field(default_factory=lambda: time.monotonic() + LOGIN_TIMEOUT)
49
+
50
+
51
+ class ChatGPTAuth:
52
+ def __init__(self, credentials: CredentialStore) -> None:
53
+ self.credentials = credentials
54
+ self._pending: dict[str, PendingLogin] = {}
55
+ self._lock = threading.RLock()
56
+ self._session_profiles: set[str] = set()
57
+
58
+ def _metadata(self) -> dict:
59
+ path = config_dir() / "chatgpt.json"
60
+ try:
61
+ return (
62
+ json.loads(path.read_text())
63
+ if path.exists()
64
+ else {"host_id": f"urn:uuid:{uuid4()}", "profiles": {}}
65
+ )
66
+ except (OSError, ValueError) as error:
67
+ raise ConfigurationError("ChatGPT connection metadata is damaged.") from error
68
+
69
+ def _save_profile(self, profile_id: str, profile: dict) -> None:
70
+ with self._lock, FileLock(str(config_dir() / "chatgpt.lock")):
71
+ metadata = self._metadata()
72
+ metadata["profiles"][profile_id] = profile
73
+ write_json(config_dir() / "chatgpt.json", metadata)
74
+
75
+ def profiles(self) -> list[dict]:
76
+ return [{"id": key, **value} for key, value in self._metadata()["profiles"].items()]
77
+
78
+ def start(self, *, profile_id: str | None = None, persist: bool = True) -> PendingLogin:
79
+ config_dir().mkdir(parents=True, exist_ok=True, mode=0o700)
80
+ with self._lock, FileLock(str(config_dir() / "chatgpt.lock")):
81
+ self._expire_pending()
82
+ if self._pending:
83
+ raise ConfigurationError("A ChatGPT sign-in is already in progress.")
84
+ metadata = self._metadata()
85
+ write_json(config_dir() / "chatgpt.json", metadata)
86
+ profile = metadata["profiles"].get(profile_id) if profile_id else None
87
+ if profile_id and not profile:
88
+ raise ConfigurationError("Unknown ChatGPT account. Choose a saved connection.")
89
+ login_id = uuid4().hex
90
+ state, nonce, verifier = (secrets.token_urlsafe(32) for _ in range(3))
91
+
92
+ auth = self
93
+
94
+ class CallbackHandler(BaseHTTPRequestHandler):
95
+ def do_GET(self) -> None:
96
+ pending = auth._pending.get(login_id)
97
+ parsed = urlsplit(self.path)
98
+ query = parse_qs(parsed.query)
99
+ valid = (
100
+ pending is not None
101
+ and parsed.path == "/auth/callback"
102
+ and len(query.get("state", [])) == 1
103
+ and secrets.compare_digest(query["state"][0], state)
104
+ and not pending.completed.is_set()
105
+ )
106
+ if not valid:
107
+ self.send_error(400, "Invalid sign-in callback")
108
+ return
109
+ pending.result = {
110
+ key: values[0] for key, values in query.items() if len(values) == 1
111
+ }
112
+ pending.completed.set()
113
+ self.send_response(200)
114
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
115
+ self.send_header("Cache-Control", "no-store")
116
+ self.send_header("Referrer-Policy", "no-referrer")
117
+ self.end_headers()
118
+ self.wfile.write(b"Return to ReadMeGen to finish connecting your account.")
119
+
120
+ def log_message(self, *_args: Any) -> None:
121
+ # Callback query strings contain authorization codes.
122
+ return
123
+
124
+ server = HTTPServer(("127.0.0.1", 0), CallbackHandler)
125
+ server.timeout = 0.5
126
+ server.socket.settimeout(2)
127
+ redirect_uri = f"http://127.0.0.1:{server.server_port}/auth/callback"
128
+ pending = PendingLogin(
129
+ login_id, state, nonce, verifier, redirect_uri, profile, persist, server
130
+ )
131
+ parameters = {
132
+ "client_id": profile["client_id"] if profile else "dynamic_agent_client",
133
+ "ext_agent_host_id": metadata["host_id"],
134
+ "response_type": "code",
135
+ "redirect_uri": redirect_uri,
136
+ "scope": f"openid profile email offline_access resource.invoke {PLAN_SCOPE}",
137
+ "resource": RESOURCE,
138
+ "state": state,
139
+ "nonce": nonce,
140
+ "code_challenge_method": "S256",
141
+ "code_challenge": base64.urlsafe_b64encode(
142
+ hashlib.sha256(verifier.encode()).digest()
143
+ )
144
+ .decode()
145
+ .rstrip("="),
146
+ }
147
+ if not profile:
148
+ parameters["agent_name_hint"] = "ReadMeGen"
149
+ pending.url = f"{ISSUER}/api/accounts/authorize?{urlencode(parameters)}"
150
+ self._pending[login_id] = pending
151
+ threading.Thread(target=self._listen, args=(pending,), daemon=True).start()
152
+ return pending
153
+
154
+ @staticmethod
155
+ def _listen(pending: PendingLogin) -> None:
156
+ try:
157
+ while not pending.completed.is_set() and time.monotonic() < pending.expires_at:
158
+ pending.server.handle_request()
159
+ except OSError:
160
+ pass
161
+ finally:
162
+ pending.server.server_close()
163
+
164
+ def _expire_pending(self) -> None:
165
+ for login_id, pending in list(self._pending.items()):
166
+ if time.monotonic() >= pending.expires_at:
167
+ pending.completed.set()
168
+ self._pending.pop(login_id)
169
+
170
+ def cancel(self, login_id: str) -> None:
171
+ with self._lock:
172
+ if pending := self._pending.pop(login_id, None):
173
+ pending.completed.set()
174
+
175
+ def close(self) -> None:
176
+ for login_id in list(self._pending):
177
+ self.cancel(login_id)
178
+
179
+ def finish(self, login_id: str) -> str | None:
180
+ with self._lock:
181
+ pending = self._pending.get(login_id)
182
+ if not pending or time.monotonic() >= pending.expires_at:
183
+ self.cancel(login_id)
184
+ raise ConfigurationError("ChatGPT sign-in expired. Start sign-in again.")
185
+ if not pending.completed.is_set():
186
+ return None
187
+ self._pending.pop(login_id)
188
+ result = pending.result
189
+ if "error" in result or not result.get("code"):
190
+ raise ConfigurationError("ChatGPT sign-in was cancelled or denied.")
191
+ client_id = result.get("client_id") or (pending.profile or {}).get("client_id")
192
+ if not client_id or client_id == "dynamic_agent_client":
193
+ raise ConfigurationError("ChatGPT registration did not return a client ID.")
194
+ if pending.profile and client_id != pending.profile["client_id"]:
195
+ raise ConfigurationError("ChatGPT returned a different account registration.")
196
+ tokens = self._token_request(
197
+ {
198
+ "grant_type": "authorization_code",
199
+ "client_id": client_id,
200
+ "code": result["code"],
201
+ "code_verifier": pending.verifier,
202
+ "redirect_uri": pending.redirect_uri,
203
+ "resource": RESOURCE,
204
+ }
205
+ )
206
+ identity = self._verify_identity(tokens.get("id_token", ""), client_id, pending.nonce)
207
+ if pending.profile and (
208
+ identity["sub"] != pending.profile["subject"]
209
+ or identity["iss"] != pending.profile["issuer"]
210
+ ):
211
+ raise ConfigurationError("The signed-in identity does not match the saved connection.")
212
+ if PLAN_SCOPE not in tokens.get("scope", "").split():
213
+ raise ConfigurationError(
214
+ "Sign-in succeeded, but permission to use the ChatGPT plan was not granted."
215
+ )
216
+ profile_id = next(
217
+ (profile["id"] for profile in self.profiles() if profile["client_id"] == client_id),
218
+ uuid4().hex,
219
+ )
220
+ record = {
221
+ **tokens,
222
+ "client_id": client_id,
223
+ "expires_at": time.time() + int(tokens.get("expires_in", 3600)),
224
+ }
225
+ self.credentials.set(f"chatgpt:{profile_id}", json.dumps(record), persist=pending.persist)
226
+ if not pending.persist:
227
+ self._session_profiles.add(profile_id)
228
+ self._save_profile(
229
+ profile_id,
230
+ {
231
+ "client_id": client_id,
232
+ "subject": identity["sub"],
233
+ "issuer": identity["iss"],
234
+ "label": f"{identity.get('email', 'ChatGPT account')} · {client_id[-8:]}",
235
+ },
236
+ )
237
+ return profile_id
238
+
239
+ @staticmethod
240
+ def _token_request(data: dict) -> dict:
241
+ try:
242
+ response = httpx.post(TOKEN_ENDPOINT, data=data, timeout=20)
243
+ response.raise_for_status()
244
+ tokens = response.json()
245
+ if not tokens.get("access_token") or tokens.get("token_type", "").lower() != "bearer":
246
+ raise ValueError("Invalid token response")
247
+ return tokens
248
+ except (httpx.HTTPError, ValueError, TypeError) as error:
249
+ raise ConfigurationError(
250
+ "ChatGPT authentication failed. Sign in again or check your connection."
251
+ ) from error
252
+
253
+ @staticmethod
254
+ def _verify_identity(token: str, client_id: str, nonce: str) -> dict:
255
+ try:
256
+ discovery = httpx.get(f"{ISSUER}/.well-known/openid-configuration", timeout=15)
257
+ discovery.raise_for_status()
258
+ uri = discovery.json()["jwks_uri"]
259
+ if urlsplit(uri).scheme != "https" or urlsplit(uri).hostname != "auth.openai.com":
260
+ raise ValueError("Unexpected JWKS host")
261
+ response = httpx.get(uri, timeout=15)
262
+ response.raise_for_status()
263
+ header = jwt.get_unverified_header(token)
264
+ key = next(item for item in response.json()["keys"] if item["kid"] == header["kid"])
265
+ identity = jwt.decode(
266
+ token,
267
+ jwt.PyJWK.from_dict(key).key,
268
+ algorithms=["RS256"],
269
+ issuer=ISSUER,
270
+ audience=client_id,
271
+ leeway=30,
272
+ options={"require": ["exp", "iss", "aud", "sub", "nonce"]},
273
+ )
274
+ if not secrets.compare_digest(identity["nonce"], nonce):
275
+ raise ValueError("Invalid nonce")
276
+ return identity
277
+ except (
278
+ httpx.HTTPError,
279
+ jwt.PyJWTError,
280
+ ValueError,
281
+ KeyError,
282
+ TypeError,
283
+ StopIteration,
284
+ ) as error:
285
+ raise ConfigurationError(
286
+ "Could not verify the ChatGPT account identity. Start sign-in again."
287
+ ) from error
288
+
289
+ def access_token(self, profile_id: str | None) -> str:
290
+ if not profile_id:
291
+ raise ConfigurationError("Connect a ChatGPT account with 'readmegen setup'.")
292
+ directory = config_dir()
293
+ directory.mkdir(parents=True, exist_ok=True, mode=0o700)
294
+ with self._lock, FileLock(str(directory / "chatgpt-refresh.lock"), timeout=30):
295
+ value = self.credentials.get(f"chatgpt:{profile_id}")
296
+ if not value:
297
+ raise ConfigurationError("This ChatGPT session is disconnected. Sign in again.")
298
+ try:
299
+ record = json.loads(value)
300
+ if record["expires_at"] <= time.time() + 60:
301
+ if not record.get("refresh_token"):
302
+ raise ConfigurationError("This ChatGPT session expired. Sign in again.")
303
+ updated = self._token_request(
304
+ {
305
+ "grant_type": "refresh_token",
306
+ "client_id": record["client_id"],
307
+ "refresh_token": record["refresh_token"],
308
+ "resource": RESOURCE,
309
+ }
310
+ )
311
+ record.update(updated)
312
+ record["expires_at"] = time.time() + int(updated.get("expires_in", 3600))
313
+ self.credentials.set(
314
+ f"chatgpt:{profile_id}",
315
+ json.dumps(record),
316
+ persist=profile_id not in self._session_profiles,
317
+ )
318
+ if PLAN_SCOPE not in record.get("scope", "").split():
319
+ raise ConfigurationError("Enable ChatGPT plan usage by signing in again.")
320
+ return record["access_token"]
321
+ except (ValueError, KeyError, TypeError) as error:
322
+ raise ConfigurationError(
323
+ "The ChatGPT session is invalid. Sign in again."
324
+ ) from error
325
+
326
+ def logout(self, profile_id: str) -> bool:
327
+ """Always clear locally; report whether remote revocation was confirmed."""
328
+ confirmed = True
329
+ value = self.credentials.get(f"chatgpt:{profile_id}")
330
+ try:
331
+ if value:
332
+ record = json.loads(value)
333
+ if record.get("refresh_token"):
334
+ discovery = httpx.get(f"{ISSUER}/.well-known/openid-configuration", timeout=15)
335
+ discovery.raise_for_status()
336
+ endpoint = discovery.json()["revocation_endpoint"]
337
+ if (
338
+ urlsplit(endpoint).scheme != "https"
339
+ or urlsplit(endpoint).hostname != "auth.openai.com"
340
+ ):
341
+ raise ValueError("Unexpected revocation host")
342
+ response = httpx.post(
343
+ endpoint,
344
+ data={
345
+ "token": record["refresh_token"],
346
+ "token_type_hint": "refresh_token",
347
+ "client_id": record["client_id"],
348
+ },
349
+ timeout=15,
350
+ )
351
+ confirmed = response.status_code == 200
352
+ except (httpx.HTTPError, ValueError, KeyError):
353
+ confirmed = False
354
+ finally:
355
+ self.credentials.delete(f"chatgpt:{profile_id}")
356
+ return confirmed
@@ -0,0 +1,60 @@
1
+ """OS credential storage with explicit, memory-only sessions on headless systems."""
2
+
3
+ import contextlib
4
+ import os
5
+
6
+ import keyring
7
+ from keyring.errors import KeyringError
8
+
9
+ from readmegen.config import Provider
10
+ from readmegen.errors import ConfigurationError
11
+
12
+ ENVIRONMENT_KEYS: dict[str, tuple[str, ...]] = {
13
+ "gemini": ("GOOGLE_API_KEY", "GEMINI_API_KEY"),
14
+ "openai": ("OPENAI_API_KEY",),
15
+ "anthropic": ("ANTHROPIC_API_KEY",),
16
+ }
17
+
18
+
19
+ class CredentialStore:
20
+ """Never silently fall back to plaintext storage."""
21
+
22
+ def __init__(self) -> None:
23
+ self._session: dict[str, str] = {}
24
+
25
+ def get(self, name: str) -> str | None:
26
+ if name in self._session:
27
+ return self._session[name]
28
+ try:
29
+ return keyring.get_password("readmegen", name)
30
+ except (KeyringError, RuntimeError):
31
+ return None
32
+
33
+ def set(self, name: str, secret: str, *, persist: bool = True) -> None:
34
+ if persist:
35
+ try:
36
+ keyring.set_password("readmegen", name, secret)
37
+ except (KeyringError, RuntimeError) as error:
38
+ raise ConfigurationError(
39
+ "The OS credential store is unavailable. Use a session-only connection "
40
+ "or set the provider's API-key environment variable."
41
+ ) from error
42
+ self._session.pop(name, None)
43
+ else:
44
+ self._session[name] = secret
45
+
46
+ def delete(self, name: str) -> None:
47
+ self._session.pop(name, None)
48
+ with contextlib.suppress(KeyringError, RuntimeError):
49
+ keyring.delete_password("readmegen", name)
50
+
51
+ def api_key(self, provider: Provider) -> str:
52
+ for name in ENVIRONMENT_KEYS[provider]:
53
+ if value := os.environ.get(name):
54
+ return value
55
+ value = self.get(f"api-key:{provider}")
56
+ if not value:
57
+ raise ConfigurationError(
58
+ f"No {provider} API key. Run 'readmegen setup' or set {ENVIRONMENT_KEYS[provider][0]}."
59
+ )
60
+ return value