ralphx 0.3.4__py3-none-any.whl → 0.3.5__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.
- ralphx/__init__.py +1 -1
- ralphx/api/routes/auth.py +703 -94
- ralphx/api/routes/config.py +3 -56
- ralphx/api/routes/export_import.py +6 -9
- ralphx/api/routes/loops.py +4 -4
- ralphx/api/routes/planning.py +19 -5
- ralphx/api/routes/templates.py +2 -2
- ralphx/api/routes/workflows.py +1 -22
- ralphx/cli.py +4 -1
- ralphx/core/auth.py +346 -171
- ralphx/core/database.py +588 -164
- ralphx/core/executor.py +0 -3
- ralphx/core/loop.py +15 -2
- ralphx/core/loop_templates.py +3 -3
- ralphx/core/planning_service.py +109 -21
- ralphx/core/preview.py +9 -25
- ralphx/core/project_db.py +124 -72
- ralphx/core/project_export.py +1 -5
- ralphx/core/project_import.py +14 -29
- ralphx/core/sample_project.py +1 -5
- ralphx/core/templates.py +9 -9
- ralphx/core/workflow_export.py +4 -7
- ralphx/core/workflow_import.py +3 -27
- ralphx/mcp/__init__.py +6 -2
- ralphx/mcp/registry.py +3 -3
- ralphx/mcp/tools/workflows.py +114 -32
- ralphx/mcp_server.py +6 -2
- ralphx/static/assets/index-0ovNnfOq.css +1 -0
- ralphx/static/assets/index-CY9s08ZB.js +251 -0
- ralphx/static/assets/index-CY9s08ZB.js.map +1 -0
- ralphx/static/index.html +2 -2
- {ralphx-0.3.4.dist-info → ralphx-0.3.5.dist-info}/METADATA +33 -12
- {ralphx-0.3.4.dist-info → ralphx-0.3.5.dist-info}/RECORD +35 -35
- ralphx/static/assets/index-CcRDyY3b.css +0 -1
- ralphx/static/assets/index-CcxfTosc.js +0 -251
- ralphx/static/assets/index-CcxfTosc.js.map +0 -1
- {ralphx-0.3.4.dist-info → ralphx-0.3.5.dist-info}/WHEEL +0 -0
- {ralphx-0.3.4.dist-info → ralphx-0.3.5.dist-info}/entry_points.txt +0 -0
ralphx/core/auth.py
CHANGED
|
@@ -140,50 +140,44 @@ class AuthStatus(BaseModel):
|
|
|
140
140
|
|
|
141
141
|
|
|
142
142
|
def get_auth_status(project_id: Optional[str] = None) -> AuthStatus:
|
|
143
|
-
"""Get auth status
|
|
143
|
+
"""Get auth status based on effective account for project.
|
|
144
144
|
|
|
145
|
-
Returns detailed status including whether project
|
|
145
|
+
Returns detailed status including whether project has a specific assignment.
|
|
146
146
|
"""
|
|
147
147
|
db = Database()
|
|
148
148
|
|
|
149
|
-
#
|
|
150
|
-
|
|
151
|
-
if project_id:
|
|
152
|
-
project_creds = db.get_credentials_by_scope("project", project_id)
|
|
149
|
+
# Get effective account (assigned to project, or default, or first active)
|
|
150
|
+
account = db.get_effective_account(project_id)
|
|
153
151
|
|
|
154
|
-
# Check
|
|
155
|
-
|
|
152
|
+
# Check if project has explicit assignment
|
|
153
|
+
has_project_assignment = False
|
|
154
|
+
if project_id:
|
|
155
|
+
assignment = db.get_project_account_assignment(project_id)
|
|
156
|
+
has_project_assignment = assignment is not None
|
|
156
157
|
|
|
157
|
-
#
|
|
158
|
-
|
|
158
|
+
# Check if using default fallback
|
|
159
|
+
using_fallback = project_id is not None and not has_project_assignment and account is not None
|
|
159
160
|
|
|
160
|
-
|
|
161
|
-
using_fallback = (
|
|
162
|
-
project_id is not None
|
|
163
|
-
and project_creds is None
|
|
164
|
-
and global_creds is not None
|
|
165
|
-
)
|
|
166
|
-
|
|
167
|
-
if not creds:
|
|
161
|
+
if not account:
|
|
168
162
|
return AuthStatus(
|
|
169
163
|
connected=False,
|
|
170
|
-
has_project_credentials=
|
|
164
|
+
has_project_credentials=has_project_assignment,
|
|
171
165
|
)
|
|
172
166
|
|
|
173
167
|
# Check expiry
|
|
174
168
|
now = int(time.time())
|
|
175
|
-
expires_at =
|
|
169
|
+
expires_at = account["expires_at"]
|
|
176
170
|
is_expired = now >= expires_at
|
|
177
171
|
|
|
178
172
|
return AuthStatus(
|
|
179
173
|
connected=True,
|
|
180
|
-
scope=
|
|
181
|
-
email=
|
|
174
|
+
scope="account", # New: accounts don't have scope like old credentials
|
|
175
|
+
email=account.get("email"),
|
|
182
176
|
expires_at=datetime.fromtimestamp(expires_at),
|
|
183
177
|
expires_in_seconds=max(0, expires_at - now),
|
|
184
178
|
is_expired=is_expired,
|
|
185
179
|
using_global_fallback=using_fallback,
|
|
186
|
-
has_project_credentials=
|
|
180
|
+
has_project_credentials=has_project_assignment,
|
|
187
181
|
)
|
|
188
182
|
|
|
189
183
|
|
|
@@ -215,17 +209,25 @@ async def _token_refresh_lock() -> AsyncGenerator[None, None]:
|
|
|
215
209
|
|
|
216
210
|
def store_oauth_tokens(
|
|
217
211
|
tokens: dict,
|
|
218
|
-
scope: Literal["project", "global"],
|
|
219
212
|
project_id: Optional[str] = None,
|
|
220
|
-
) ->
|
|
221
|
-
"""Store OAuth tokens in database.
|
|
213
|
+
) -> dict:
|
|
214
|
+
"""Store OAuth tokens in database (accounts table).
|
|
222
215
|
|
|
223
216
|
Args:
|
|
224
|
-
tokens: Dict with access_token, refresh_token, expires_in, email (
|
|
217
|
+
tokens: Dict with access_token, refresh_token, expires_in, email (required),
|
|
225
218
|
scopes (optional), subscription_type (optional), rate_limit_tier (optional)
|
|
226
|
-
|
|
227
|
-
|
|
219
|
+
project_id: Optional project ID to assign this account to
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Account dict with id, email, and other fields
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
ValueError: If email not provided in tokens
|
|
228
226
|
"""
|
|
227
|
+
email = tokens.get("email")
|
|
228
|
+
if not email:
|
|
229
|
+
raise ValueError("Email is required to store OAuth tokens")
|
|
230
|
+
|
|
229
231
|
db = Database()
|
|
230
232
|
expires_at = int(time.time()) + tokens.get("expires_in", 28800)
|
|
231
233
|
|
|
@@ -234,43 +236,209 @@ def store_oauth_tokens(
|
|
|
234
236
|
if tokens.get("scopes"):
|
|
235
237
|
scopes_json = json.dumps(tokens["scopes"])
|
|
236
238
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
+
# Create or update account
|
|
240
|
+
account = db.create_account(
|
|
241
|
+
email=email,
|
|
239
242
|
access_token=tokens["access_token"],
|
|
240
243
|
refresh_token=tokens.get("refresh_token"),
|
|
241
244
|
expires_at=expires_at,
|
|
242
|
-
project_id=project_id if scope == "project" else None,
|
|
243
|
-
email=tokens.get("email"),
|
|
244
245
|
scopes=scopes_json,
|
|
245
246
|
subscription_type=tokens.get("subscription_type"),
|
|
246
247
|
rate_limit_tier=tokens.get("rate_limit_tier"),
|
|
247
248
|
)
|
|
248
249
|
|
|
250
|
+
# If project_id provided, assign this account to the project
|
|
251
|
+
if project_id:
|
|
252
|
+
db.assign_account_to_project(project_id, account["id"])
|
|
253
|
+
|
|
249
254
|
auth_log.info(
|
|
250
255
|
"login",
|
|
251
|
-
f"Logged in
|
|
252
|
-
|
|
253
|
-
email=tokens.get("email"),
|
|
256
|
+
f"Logged in as {email}",
|
|
257
|
+
email=email,
|
|
254
258
|
project_id=project_id,
|
|
255
259
|
)
|
|
256
|
-
return
|
|
260
|
+
return account
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def get_effective_account_for_project(project_id: Optional[str] = None) -> Optional[dict]:
|
|
264
|
+
"""Get the effective account for a project.
|
|
265
|
+
|
|
266
|
+
Resolution order:
|
|
267
|
+
1. If project has assignment -> use that account
|
|
268
|
+
2. Else -> use default account
|
|
269
|
+
3. If no default -> use first active account
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
project_id: Optional project ID
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
Account dict or None
|
|
276
|
+
"""
|
|
277
|
+
db = Database()
|
|
278
|
+
return db.get_effective_account(project_id)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def get_fallback_account_for_rate_limit(
|
|
282
|
+
current_account_id: int,
|
|
283
|
+
failed_account_ids: Optional[list[int]] = None,
|
|
284
|
+
) -> Optional[dict]:
|
|
285
|
+
"""Get a fallback account after hitting rate limit (429).
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
current_account_id: Account that hit the rate limit
|
|
289
|
+
failed_account_ids: List of account IDs that already failed
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
Account dict for fallback, or None if no fallback available
|
|
293
|
+
"""
|
|
294
|
+
db = Database()
|
|
295
|
+
exclude_ids = [current_account_id] + (failed_account_ids or [])
|
|
296
|
+
return db.get_fallback_account(exclude_ids=exclude_ids, prefer_lowest_usage=True)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@contextmanager
|
|
300
|
+
def swap_credentials_for_account(
|
|
301
|
+
account: dict,
|
|
302
|
+
) -> Generator[bool, None, None]:
|
|
303
|
+
"""Context manager: backup user creds, write account creds, restore after.
|
|
304
|
+
|
|
305
|
+
Similar to swap_credentials_for_loop but takes an account dict directly.
|
|
306
|
+
Used when we have a specific account to use (e.g., from fallback logic).
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
account: Account dict with access_token, refresh_token, etc.
|
|
310
|
+
|
|
311
|
+
Yields:
|
|
312
|
+
True if credentials were written, False if account invalid
|
|
313
|
+
"""
|
|
314
|
+
global _credential_swap_active
|
|
315
|
+
|
|
316
|
+
db = Database()
|
|
317
|
+
|
|
318
|
+
# Acquire exclusive lock to prevent concurrent credential access
|
|
319
|
+
CREDENTIAL_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
lock_file = open(CREDENTIAL_LOCK_PATH, "w")
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) # Exclusive lock
|
|
324
|
+
|
|
325
|
+
# Backup user's current credentials
|
|
326
|
+
had_backup = False
|
|
327
|
+
original_content = None
|
|
328
|
+
if CLAUDE_CREDENTIALS_PATH.exists():
|
|
329
|
+
original_content = CLAUDE_CREDENTIALS_PATH.read_text()
|
|
330
|
+
shutil.copy2(CLAUDE_CREDENTIALS_PATH, CLAUDE_CREDENTIALS_BACKUP)
|
|
331
|
+
had_backup = True
|
|
332
|
+
|
|
333
|
+
_credential_swap_active = True
|
|
334
|
+
|
|
335
|
+
# Write account credentials to Claude's location
|
|
336
|
+
has_creds = False
|
|
337
|
+
if account and account.get("access_token"):
|
|
338
|
+
default_scopes = ["user:inference", "user:profile", "user:sessions:claude_code"]
|
|
339
|
+
stored_scopes = account.get("scopes")
|
|
340
|
+
if stored_scopes:
|
|
341
|
+
try:
|
|
342
|
+
scopes = json.loads(stored_scopes)
|
|
343
|
+
except (json.JSONDecodeError, TypeError):
|
|
344
|
+
scopes = default_scopes
|
|
345
|
+
else:
|
|
346
|
+
scopes = default_scopes
|
|
347
|
+
|
|
348
|
+
creds_data = {
|
|
349
|
+
"claudeAiOauth": {
|
|
350
|
+
"accessToken": account["access_token"],
|
|
351
|
+
"refreshToken": account.get("refresh_token"),
|
|
352
|
+
"expiresAt": account.get("expires_at", 0) * 1000,
|
|
353
|
+
"scopes": scopes,
|
|
354
|
+
"subscriptionType": account.get("subscription_type") or "max",
|
|
355
|
+
"rateLimitTier": account.get("rate_limit_tier") or "default_claude_max_20x",
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
CLAUDE_CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
359
|
+
CLAUDE_CREDENTIALS_PATH.write_text(json.dumps(creds_data, indent=2))
|
|
360
|
+
has_creds = True
|
|
361
|
+
|
|
362
|
+
# Update last_used_at for the account
|
|
363
|
+
db.update_account(account["id"], last_used_at=datetime.utcnow().isoformat())
|
|
257
364
|
|
|
365
|
+
try:
|
|
366
|
+
yield has_creds
|
|
367
|
+
finally:
|
|
368
|
+
# Capture any token refresh that happened during execution
|
|
369
|
+
if has_creds and CLAUDE_CREDENTIALS_PATH.exists():
|
|
370
|
+
try:
|
|
371
|
+
current_creds = json.loads(CLAUDE_CREDENTIALS_PATH.read_text())
|
|
372
|
+
oauth = current_creds.get("claudeAiOauth", {})
|
|
373
|
+
new_refresh = oauth.get("refreshToken")
|
|
258
374
|
|
|
259
|
-
|
|
260
|
-
|
|
375
|
+
if new_refresh and new_refresh != account.get("refresh_token"):
|
|
376
|
+
db.update_account(
|
|
377
|
+
account["id"],
|
|
378
|
+
access_token=oauth.get("accessToken"),
|
|
379
|
+
refresh_token=new_refresh,
|
|
380
|
+
expires_at=int(oauth.get("expiresAt", 0) / 1000),
|
|
381
|
+
)
|
|
382
|
+
auth_log.info(
|
|
383
|
+
"account_token_captured",
|
|
384
|
+
f"Captured refreshed token for account {account.get('email')}",
|
|
385
|
+
account_id=account["id"],
|
|
386
|
+
)
|
|
387
|
+
except Exception as e:
|
|
388
|
+
auth_log.warning(
|
|
389
|
+
"account_token_capture_failed",
|
|
390
|
+
f"Failed to capture refreshed token for account: {e}",
|
|
391
|
+
account_id=account.get("id"),
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
# Restore user's original credentials
|
|
395
|
+
restoration_success = False
|
|
396
|
+
try:
|
|
397
|
+
if had_backup and CLAUDE_CREDENTIALS_BACKUP.exists():
|
|
398
|
+
shutil.copy2(CLAUDE_CREDENTIALS_BACKUP, CLAUDE_CREDENTIALS_PATH)
|
|
399
|
+
restoration_success = True
|
|
400
|
+
elif not had_backup and has_creds:
|
|
401
|
+
CLAUDE_CREDENTIALS_PATH.unlink(missing_ok=True)
|
|
402
|
+
restoration_success = True
|
|
403
|
+
else:
|
|
404
|
+
restoration_success = True
|
|
405
|
+
except Exception as e:
|
|
406
|
+
auth_log.error(
|
|
407
|
+
"account_restoration_failed",
|
|
408
|
+
f"Failed to restore credentials after account swap: {e}",
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
if restoration_success and CLAUDE_CREDENTIALS_BACKUP.exists():
|
|
412
|
+
try:
|
|
413
|
+
CLAUDE_CREDENTIALS_BACKUP.unlink()
|
|
414
|
+
except Exception:
|
|
415
|
+
pass
|
|
416
|
+
|
|
417
|
+
_credential_swap_active = False
|
|
418
|
+
finally:
|
|
419
|
+
_credential_swap_active = False
|
|
420
|
+
try:
|
|
421
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
422
|
+
lock_file.close()
|
|
423
|
+
except Exception:
|
|
424
|
+
pass
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
async def _do_token_refresh(account: dict, project_id: Optional[str] = None) -> bool:
|
|
428
|
+
"""Actually perform the token refresh via OAuth for an account.
|
|
261
429
|
|
|
262
430
|
Args:
|
|
263
|
-
|
|
431
|
+
account: Account dict from database with id, refresh_token, email
|
|
264
432
|
project_id: Optional project ID for logging
|
|
265
433
|
|
|
266
434
|
Returns:
|
|
267
435
|
True if refresh succeeded, False otherwise
|
|
268
436
|
"""
|
|
269
|
-
if not
|
|
437
|
+
if not account.get("refresh_token"):
|
|
270
438
|
auth_log.warning(
|
|
271
439
|
"token_refresh_failed",
|
|
272
|
-
f"No refresh token available
|
|
273
|
-
|
|
440
|
+
f"No refresh token available for account {account.get('email', 'unknown')}",
|
|
441
|
+
email=account.get("email"),
|
|
274
442
|
project_id=project_id,
|
|
275
443
|
)
|
|
276
444
|
return False
|
|
@@ -282,7 +450,7 @@ async def _do_token_refresh(creds: dict, project_id: Optional[str] = None) -> bo
|
|
|
282
450
|
TOKEN_URL,
|
|
283
451
|
json={
|
|
284
452
|
"grant_type": "refresh_token",
|
|
285
|
-
"refresh_token":
|
|
453
|
+
"refresh_token": account["refresh_token"],
|
|
286
454
|
"client_id": CLIENT_ID,
|
|
287
455
|
},
|
|
288
456
|
headers={
|
|
@@ -295,8 +463,9 @@ async def _do_token_refresh(creds: dict, project_id: Optional[str] = None) -> bo
|
|
|
295
463
|
error_body = resp.text[:200] if resp.text else "no response body"
|
|
296
464
|
auth_log.warning(
|
|
297
465
|
"token_refresh_failed",
|
|
298
|
-
f"Token refresh failed (
|
|
299
|
-
|
|
466
|
+
f"Token refresh failed for {account.get('email')}: HTTP {resp.status_code} - {error_body}",
|
|
467
|
+
email=account.get("email"),
|
|
468
|
+
account_id=account.get("id"),
|
|
300
469
|
project_id=project_id,
|
|
301
470
|
status_code=resp.status_code,
|
|
302
471
|
)
|
|
@@ -304,6 +473,8 @@ async def _do_token_refresh(creds: dict, project_id: Optional[str] = None) -> bo
|
|
|
304
473
|
try:
|
|
305
474
|
error_json = resp.json()
|
|
306
475
|
if error_json.get("error") == "invalid_grant":
|
|
476
|
+
# Mark account as inactive since token is revoked
|
|
477
|
+
db.update_account(account["id"], is_active=False, last_error="invalid_grant")
|
|
307
478
|
raise InvalidGrantError("Refresh token expired or revoked. Please re-login.")
|
|
308
479
|
except (ValueError, KeyError):
|
|
309
480
|
pass
|
|
@@ -312,33 +483,37 @@ async def _do_token_refresh(creds: dict, project_id: Optional[str] = None) -> bo
|
|
|
312
483
|
tokens = resp.json()
|
|
313
484
|
new_expires_at = int(time.time()) + tokens.get("expires_in", 28800)
|
|
314
485
|
|
|
315
|
-
#
|
|
316
|
-
|
|
317
|
-
|
|
486
|
+
# Build update dict - capture subscription/plan info if present
|
|
487
|
+
update_data = {
|
|
488
|
+
"access_token": tokens["access_token"],
|
|
489
|
+
"refresh_token": tokens.get("refresh_token", account["refresh_token"]),
|
|
490
|
+
"expires_at": new_expires_at,
|
|
491
|
+
"consecutive_failures": 0, # Reset failure count on success
|
|
492
|
+
}
|
|
493
|
+
if tokens.get("subscription_type"):
|
|
494
|
+
update_data["subscription_type"] = tokens["subscription_type"]
|
|
495
|
+
if tokens.get("rate_limit_tier"):
|
|
496
|
+
update_data["rate_limit_tier"] = tokens["rate_limit_tier"]
|
|
318
497
|
|
|
319
498
|
# CRITICAL: If DB update fails after refresh, we've consumed the
|
|
320
499
|
# refresh_token but not saved the new one. Log this clearly.
|
|
321
500
|
try:
|
|
322
|
-
db.
|
|
323
|
-
creds["id"],
|
|
324
|
-
access_token=tokens["access_token"],
|
|
325
|
-
refresh_token=tokens.get("refresh_token", creds["refresh_token"]),
|
|
326
|
-
expires_at=new_expires_at,
|
|
327
|
-
email=email if email else creds.get("email"),
|
|
328
|
-
)
|
|
501
|
+
db.update_account(account["id"], **update_data)
|
|
329
502
|
except Exception as db_error:
|
|
330
503
|
auth_log.error(
|
|
331
504
|
"token_db_update_failed",
|
|
332
|
-
f"Failed to save refreshed token (
|
|
333
|
-
|
|
505
|
+
f"Failed to save refreshed token for {account.get('email')}: {db_error}. Token may be lost!",
|
|
506
|
+
email=account.get("email"),
|
|
507
|
+
account_id=account.get("id"),
|
|
334
508
|
project_id=project_id,
|
|
335
509
|
)
|
|
336
510
|
return False
|
|
337
511
|
|
|
338
512
|
auth_log.info(
|
|
339
513
|
"token_refresh",
|
|
340
|
-
f"Token refreshed (
|
|
341
|
-
|
|
514
|
+
f"Token refreshed for {account.get('email')}",
|
|
515
|
+
email=account.get("email"),
|
|
516
|
+
account_id=account.get("id"),
|
|
342
517
|
project_id=project_id,
|
|
343
518
|
expires_in=tokens.get("expires_in", 28800),
|
|
344
519
|
)
|
|
@@ -346,53 +521,55 @@ async def _do_token_refresh(creds: dict, project_id: Optional[str] = None) -> bo
|
|
|
346
521
|
except httpx.HTTPError as http_error:
|
|
347
522
|
auth_log.warning(
|
|
348
523
|
"token_refresh_failed",
|
|
349
|
-
f"Token refresh HTTP error (
|
|
350
|
-
|
|
524
|
+
f"Token refresh HTTP error for {account.get('email')}: {http_error}",
|
|
525
|
+
email=account.get("email"),
|
|
526
|
+
account_id=account.get("id"),
|
|
351
527
|
project_id=project_id,
|
|
352
528
|
)
|
|
353
529
|
return False
|
|
354
530
|
except Exception as e:
|
|
355
531
|
auth_log.warning(
|
|
356
532
|
"token_refresh_failed",
|
|
357
|
-
f"Token refresh failed (
|
|
358
|
-
|
|
533
|
+
f"Token refresh failed for {account.get('email')}: {e}",
|
|
534
|
+
email=account.get("email"),
|
|
535
|
+
account_id=account.get("id"),
|
|
359
536
|
project_id=project_id,
|
|
360
537
|
)
|
|
361
538
|
return False
|
|
362
539
|
|
|
363
540
|
|
|
364
|
-
async def
|
|
365
|
-
"""Validate OAuth token by attempting a refresh.
|
|
541
|
+
async def validate_account_token(account: dict) -> tuple[bool, str]:
|
|
542
|
+
"""Validate account's OAuth token by attempting a refresh.
|
|
366
543
|
|
|
367
544
|
Since we're using OAuth tokens (not API keys), we validate by
|
|
368
545
|
trying to refresh the token. If the refresh_token is still valid,
|
|
369
546
|
we'll get a new access_token. If it's expired/revoked, we get an error.
|
|
370
547
|
|
|
371
548
|
Args:
|
|
372
|
-
|
|
549
|
+
account: Account dict with refresh_token
|
|
373
550
|
|
|
374
551
|
Returns:
|
|
375
552
|
Tuple of (is_valid, error_message)
|
|
376
553
|
"""
|
|
377
|
-
if not
|
|
378
|
-
return False, "No
|
|
554
|
+
if not account:
|
|
555
|
+
return False, "No account"
|
|
379
556
|
|
|
380
|
-
if not
|
|
557
|
+
if not account.get("refresh_token"):
|
|
381
558
|
return False, "No refresh token available"
|
|
382
559
|
|
|
383
560
|
try:
|
|
384
561
|
# Use lock to prevent concurrent refresh operations.
|
|
385
|
-
# Re-read
|
|
562
|
+
# Re-read account inside lock in case another process already refreshed.
|
|
386
563
|
async with _token_refresh_lock():
|
|
387
|
-
# Re-fetch
|
|
564
|
+
# Re-fetch account to get any updates from concurrent refresh
|
|
388
565
|
db = Database()
|
|
389
|
-
|
|
390
|
-
if
|
|
566
|
+
fresh_account = db.get_account(account["id"])
|
|
567
|
+
if fresh_account and fresh_account.get("refresh_token") != account.get("refresh_token"):
|
|
391
568
|
# Another process already refreshed - we have fresh tokens
|
|
392
569
|
auth_log.info(
|
|
393
570
|
"token_already_refreshed",
|
|
394
571
|
"Token was refreshed by another process",
|
|
395
|
-
|
|
572
|
+
email=account.get("email"),
|
|
396
573
|
)
|
|
397
574
|
return True, ""
|
|
398
575
|
|
|
@@ -402,7 +579,7 @@ async def validate_token_health(creds: dict) -> tuple[bool, str]:
|
|
|
402
579
|
TOKEN_URL,
|
|
403
580
|
json={
|
|
404
581
|
"grant_type": "refresh_token",
|
|
405
|
-
"refresh_token":
|
|
582
|
+
"refresh_token": account["refresh_token"],
|
|
406
583
|
"client_id": CLIENT_ID,
|
|
407
584
|
},
|
|
408
585
|
headers={
|
|
@@ -415,32 +592,35 @@ async def validate_token_health(creds: dict) -> tuple[bool, str]:
|
|
|
415
592
|
# Token is valid and we got a new one - update it
|
|
416
593
|
tokens = resp.json()
|
|
417
594
|
new_expires_at = int(time.time()) + tokens.get("expires_in", 28800)
|
|
418
|
-
account = tokens.get("account", {})
|
|
419
|
-
email = account.get("email_address")
|
|
420
595
|
|
|
421
|
-
#
|
|
596
|
+
# Build update dict - capture subscription/plan info if present
|
|
597
|
+
update_data = {
|
|
598
|
+
"access_token": tokens["access_token"],
|
|
599
|
+
"refresh_token": tokens.get("refresh_token", account["refresh_token"]),
|
|
600
|
+
"expires_at": new_expires_at,
|
|
601
|
+
"consecutive_failures": 0,
|
|
602
|
+
}
|
|
603
|
+
if tokens.get("subscription_type"):
|
|
604
|
+
update_data["subscription_type"] = tokens["subscription_type"]
|
|
605
|
+
if tokens.get("rate_limit_tier"):
|
|
606
|
+
update_data["rate_limit_tier"] = tokens["rate_limit_tier"]
|
|
607
|
+
|
|
422
608
|
# CRITICAL: If DB update fails, we've consumed the refresh_token
|
|
423
609
|
# but not saved the new one. Handle this carefully.
|
|
424
610
|
try:
|
|
425
|
-
db.
|
|
426
|
-
creds["id"],
|
|
427
|
-
access_token=tokens["access_token"],
|
|
428
|
-
refresh_token=tokens.get("refresh_token", creds["refresh_token"]),
|
|
429
|
-
expires_at=new_expires_at,
|
|
430
|
-
email=email if email else creds.get("email"),
|
|
431
|
-
)
|
|
611
|
+
db.update_account(account["id"], **update_data)
|
|
432
612
|
except Exception as db_error:
|
|
433
613
|
auth_log.error(
|
|
434
614
|
"token_db_update_failed",
|
|
435
615
|
f"Failed to save refreshed token: {db_error}. Token may be lost!",
|
|
436
|
-
|
|
616
|
+
email=account.get("email"),
|
|
437
617
|
)
|
|
438
618
|
return False, f"Token refresh succeeded but failed to save: {db_error}"
|
|
439
619
|
|
|
440
620
|
auth_log.info(
|
|
441
621
|
"token_validated",
|
|
442
|
-
f"Token validated and refreshed
|
|
443
|
-
|
|
622
|
+
f"Token validated and refreshed for {account.get('email')}",
|
|
623
|
+
email=account.get("email"),
|
|
444
624
|
)
|
|
445
625
|
return True, ""
|
|
446
626
|
|
|
@@ -450,6 +630,8 @@ async def validate_token_health(creds: dict) -> tuple[bool, str]:
|
|
|
450
630
|
error_type = error_json.get("error", "unknown")
|
|
451
631
|
error_desc = error_json.get("error_description", "")
|
|
452
632
|
if error_type == "invalid_grant":
|
|
633
|
+
# Mark account as needing re-auth
|
|
634
|
+
db.update_account(account["id"], is_active=False, last_error="invalid_grant")
|
|
453
635
|
return False, f"Refresh token expired or revoked: {error_desc}. Please re-login."
|
|
454
636
|
return False, f"OAuth error: {error_type} - {error_desc}"
|
|
455
637
|
except Exception:
|
|
@@ -493,65 +675,65 @@ async def refresh_token_if_needed(
|
|
|
493
675
|
"""Auto-refresh token if expired. Returns True if valid token available.
|
|
494
676
|
|
|
495
677
|
Args:
|
|
496
|
-
project_id: Optional project ID to
|
|
678
|
+
project_id: Optional project ID to get effective account for
|
|
497
679
|
validate: If True, actually validate the token by calling API
|
|
498
680
|
|
|
499
681
|
Returns:
|
|
500
|
-
True if valid
|
|
501
|
-
False if no
|
|
682
|
+
True if valid account exists (either not expired or successfully refreshed)
|
|
683
|
+
False if no account or refresh failed
|
|
502
684
|
"""
|
|
503
685
|
db = Database()
|
|
504
|
-
|
|
686
|
+
account = db.get_effective_account(project_id)
|
|
505
687
|
|
|
506
|
-
if not
|
|
688
|
+
if not account:
|
|
507
689
|
return False
|
|
508
690
|
|
|
509
691
|
now = int(time.time())
|
|
510
692
|
|
|
511
693
|
# If validation requested, actually test the token
|
|
512
|
-
# Note:
|
|
694
|
+
# Note: validate_account_token() attempts a refresh as part of validation.
|
|
513
695
|
# If it fails, the refresh already failed - no point retrying immediately.
|
|
514
696
|
if validate:
|
|
515
|
-
is_valid, error = await
|
|
697
|
+
is_valid, error = await validate_account_token(account)
|
|
516
698
|
if not is_valid:
|
|
517
699
|
auth_log.warning(
|
|
518
700
|
"token_invalid",
|
|
519
701
|
f"Token validation failed: {error}",
|
|
520
|
-
|
|
702
|
+
email=account.get("email"),
|
|
521
703
|
project_id=project_id,
|
|
522
704
|
)
|
|
523
|
-
# Don't retry -
|
|
705
|
+
# Don't retry - validate_account_token already tried to refresh.
|
|
524
706
|
# The error message explains what went wrong.
|
|
525
707
|
return False
|
|
526
708
|
return True
|
|
527
709
|
|
|
528
710
|
# Otherwise just check expiry timestamp (5 minute buffer)
|
|
529
|
-
if now <
|
|
711
|
+
if now < account["expires_at"] - 300:
|
|
530
712
|
return True # Token assumed valid based on expiry
|
|
531
713
|
|
|
532
|
-
return await _do_token_refresh(
|
|
714
|
+
return await _do_token_refresh(account, project_id)
|
|
533
715
|
|
|
534
716
|
|
|
535
717
|
async def force_refresh_token(project_id: Optional[str] = None) -> dict:
|
|
536
718
|
"""Force refresh a token regardless of expiry time.
|
|
537
719
|
|
|
538
720
|
Args:
|
|
539
|
-
project_id: Optional project ID to
|
|
721
|
+
project_id: Optional project ID to get effective account for
|
|
540
722
|
|
|
541
723
|
Returns:
|
|
542
724
|
dict with success status and message
|
|
543
725
|
"""
|
|
544
726
|
db = Database()
|
|
545
|
-
|
|
727
|
+
account = db.get_effective_account(project_id)
|
|
546
728
|
|
|
547
|
-
if not
|
|
548
|
-
return {"success": False, "error": "No
|
|
729
|
+
if not account:
|
|
730
|
+
return {"success": False, "error": "No account found"}
|
|
549
731
|
|
|
550
|
-
if not
|
|
732
|
+
if not account.get("refresh_token"):
|
|
551
733
|
return {"success": False, "error": "No refresh token available"}
|
|
552
734
|
|
|
553
735
|
try:
|
|
554
|
-
success = await _do_token_refresh(
|
|
736
|
+
success = await _do_token_refresh(account, project_id)
|
|
555
737
|
if success:
|
|
556
738
|
return {"success": True, "message": "Token refreshed successfully"}
|
|
557
739
|
else:
|
|
@@ -561,7 +743,7 @@ async def force_refresh_token(project_id: Optional[str] = None) -> dict:
|
|
|
561
743
|
|
|
562
744
|
|
|
563
745
|
async def refresh_all_expiring_tokens(buffer_seconds: int = 7200) -> dict:
|
|
564
|
-
"""Refresh all tokens expiring within buffer_seconds.
|
|
746
|
+
"""Refresh all account tokens expiring within buffer_seconds.
|
|
565
747
|
|
|
566
748
|
Called by background task to proactively keep tokens fresh.
|
|
567
749
|
|
|
@@ -575,26 +757,26 @@ async def refresh_all_expiring_tokens(buffer_seconds: int = 7200) -> dict:
|
|
|
575
757
|
now = int(time.time())
|
|
576
758
|
result = {"checked": 0, "refreshed": 0, "failed": 0}
|
|
577
759
|
|
|
578
|
-
# Get all
|
|
579
|
-
|
|
760
|
+
# Get all active accounts
|
|
761
|
+
all_accounts = db.list_accounts(include_inactive=False, include_deleted=False)
|
|
580
762
|
|
|
581
|
-
for
|
|
763
|
+
for account in all_accounts:
|
|
582
764
|
result["checked"] += 1
|
|
583
765
|
|
|
584
766
|
# Skip if not expiring soon
|
|
585
|
-
if now <
|
|
767
|
+
if now < account["expires_at"] - buffer_seconds:
|
|
586
768
|
continue
|
|
587
769
|
|
|
588
770
|
# Skip if no refresh token
|
|
589
|
-
if not
|
|
771
|
+
if not account.get("refresh_token"):
|
|
590
772
|
continue
|
|
591
773
|
|
|
592
774
|
# Attempt refresh with lock to prevent race conditions
|
|
593
775
|
try:
|
|
594
776
|
async with _token_refresh_lock():
|
|
595
|
-
# Re-fetch
|
|
596
|
-
|
|
597
|
-
if
|
|
777
|
+
# Re-fetch account to check if another process already refreshed
|
|
778
|
+
fresh_account = db.get_account(account["id"])
|
|
779
|
+
if fresh_account and now < fresh_account["expires_at"] - buffer_seconds:
|
|
598
780
|
# Token was refreshed by another process, skip
|
|
599
781
|
continue
|
|
600
782
|
|
|
@@ -603,7 +785,7 @@ async def refresh_all_expiring_tokens(buffer_seconds: int = 7200) -> dict:
|
|
|
603
785
|
TOKEN_URL,
|
|
604
786
|
json={
|
|
605
787
|
"grant_type": "refresh_token",
|
|
606
|
-
"refresh_token":
|
|
788
|
+
"refresh_token": account["refresh_token"],
|
|
607
789
|
"client_id": CLIENT_ID,
|
|
608
790
|
},
|
|
609
791
|
headers={
|
|
@@ -615,72 +797,63 @@ async def refresh_all_expiring_tokens(buffer_seconds: int = 7200) -> dict:
|
|
|
615
797
|
if resp.status_code == 200:
|
|
616
798
|
tokens = resp.json()
|
|
617
799
|
new_expires_at = int(time.time()) + tokens.get("expires_in", 28800)
|
|
618
|
-
|
|
619
|
-
|
|
800
|
+
|
|
801
|
+
# Build update dict - capture subscription/plan info if present
|
|
802
|
+
update_data = {
|
|
803
|
+
"access_token": tokens["access_token"],
|
|
804
|
+
"refresh_token": tokens.get("refresh_token", account["refresh_token"]),
|
|
805
|
+
"expires_at": new_expires_at,
|
|
806
|
+
"consecutive_failures": 0,
|
|
807
|
+
}
|
|
808
|
+
if tokens.get("subscription_type"):
|
|
809
|
+
update_data["subscription_type"] = tokens["subscription_type"]
|
|
810
|
+
if tokens.get("rate_limit_tier"):
|
|
811
|
+
update_data["rate_limit_tier"] = tokens["rate_limit_tier"]
|
|
620
812
|
|
|
621
813
|
try:
|
|
622
|
-
db.
|
|
623
|
-
creds["id"],
|
|
624
|
-
access_token=tokens["access_token"],
|
|
625
|
-
refresh_token=tokens.get("refresh_token", creds["refresh_token"]),
|
|
626
|
-
expires_at=new_expires_at,
|
|
627
|
-
email=email if email else creds.get("email"),
|
|
628
|
-
)
|
|
814
|
+
db.update_account(account["id"], **update_data)
|
|
629
815
|
result["refreshed"] += 1
|
|
630
816
|
auth_log.info(
|
|
631
817
|
"token_refresh",
|
|
632
|
-
f"Token refreshed (
|
|
633
|
-
|
|
634
|
-
|
|
818
|
+
f"Token refreshed for {account.get('email')}",
|
|
819
|
+
email=account.get("email"),
|
|
820
|
+
account_id=account.get("id"),
|
|
635
821
|
expires_in=tokens.get("expires_in", 28800),
|
|
636
822
|
)
|
|
637
823
|
except Exception as db_error:
|
|
638
824
|
result["failed"] += 1
|
|
639
825
|
auth_log.error(
|
|
640
826
|
"token_db_update_failed",
|
|
641
|
-
f"Failed to save refreshed token (
|
|
642
|
-
|
|
643
|
-
|
|
827
|
+
f"Failed to save refreshed token for {account.get('email')}: {db_error}",
|
|
828
|
+
email=account.get("email"),
|
|
829
|
+
account_id=account.get("id"),
|
|
644
830
|
)
|
|
645
831
|
else:
|
|
646
832
|
result["failed"] += 1
|
|
833
|
+
# Track consecutive failures
|
|
834
|
+
db.update_account(
|
|
835
|
+
account["id"],
|
|
836
|
+
consecutive_failures=account.get("consecutive_failures", 0) + 1,
|
|
837
|
+
)
|
|
647
838
|
auth_log.warning(
|
|
648
839
|
"token_refresh_failed",
|
|
649
|
-
f"Token refresh failed (
|
|
650
|
-
|
|
651
|
-
|
|
840
|
+
f"Token refresh failed for {account.get('email')}: HTTP {resp.status_code}",
|
|
841
|
+
email=account.get("email"),
|
|
842
|
+
account_id=account.get("id"),
|
|
652
843
|
)
|
|
653
844
|
except Exception as e:
|
|
654
845
|
result["failed"] += 1
|
|
655
846
|
auth_log.error(
|
|
656
847
|
"token_refresh_error",
|
|
657
|
-
f"Token refresh error (
|
|
658
|
-
|
|
659
|
-
|
|
848
|
+
f"Token refresh error for {account.get('email')}: {e}",
|
|
849
|
+
email=account.get("email"),
|
|
850
|
+
account_id=account.get("id"),
|
|
660
851
|
error=str(e),
|
|
661
852
|
)
|
|
662
853
|
|
|
663
854
|
return result
|
|
664
855
|
|
|
665
856
|
|
|
666
|
-
def clear_credentials(
|
|
667
|
-
scope: Literal["project", "global"],
|
|
668
|
-
project_id: Optional[str] = None,
|
|
669
|
-
) -> bool:
|
|
670
|
-
"""Remove credentials for the specified scope from database."""
|
|
671
|
-
db = Database()
|
|
672
|
-
result = db.delete_credentials(scope, project_id if scope == "project" else None)
|
|
673
|
-
|
|
674
|
-
if result:
|
|
675
|
-
auth_log.info(
|
|
676
|
-
"logout",
|
|
677
|
-
f"Logged out ({scope})",
|
|
678
|
-
scope=scope,
|
|
679
|
-
project_id=project_id,
|
|
680
|
-
)
|
|
681
|
-
return result
|
|
682
|
-
|
|
683
|
-
|
|
684
857
|
@contextmanager
|
|
685
858
|
def swap_credentials_for_loop(
|
|
686
859
|
project_id: Optional[str] = None,
|
|
@@ -711,7 +884,8 @@ def swap_credentials_for_loop(
|
|
|
711
884
|
global _credential_swap_active
|
|
712
885
|
|
|
713
886
|
db = Database()
|
|
714
|
-
|
|
887
|
+
# Get the effective account for this project (assigned or default)
|
|
888
|
+
account = db.get_effective_account(project_id)
|
|
715
889
|
|
|
716
890
|
# Acquire exclusive lock to prevent concurrent credential access
|
|
717
891
|
CREDENTIAL_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -732,13 +906,13 @@ def swap_credentials_for_loop(
|
|
|
732
906
|
# Mark swap as active AFTER backup is complete
|
|
733
907
|
_credential_swap_active = True
|
|
734
908
|
|
|
735
|
-
# Write credentials
|
|
909
|
+
# Write account credentials to Claude's location
|
|
736
910
|
has_creds = False
|
|
737
|
-
if
|
|
738
|
-
# Default scopes if not stored
|
|
911
|
+
if account:
|
|
912
|
+
# Default scopes if not stored
|
|
739
913
|
# These are the minimum scopes Claude Code CLI needs for execution
|
|
740
914
|
default_scopes = ["user:inference", "user:profile", "user:sessions:claude_code"]
|
|
741
|
-
stored_scopes =
|
|
915
|
+
stored_scopes = account.get("scopes")
|
|
742
916
|
if stored_scopes:
|
|
743
917
|
try:
|
|
744
918
|
scopes = json.loads(stored_scopes)
|
|
@@ -749,16 +923,16 @@ def swap_credentials_for_loop(
|
|
|
749
923
|
|
|
750
924
|
creds_data = {
|
|
751
925
|
"claudeAiOauth": {
|
|
752
|
-
"accessToken":
|
|
753
|
-
"refreshToken":
|
|
754
|
-
"expiresAt":
|
|
926
|
+
"accessToken": account["access_token"],
|
|
927
|
+
"refreshToken": account["refresh_token"],
|
|
928
|
+
"expiresAt": account["expires_at"] * 1000, # Convert to milliseconds
|
|
755
929
|
"scopes": scopes,
|
|
756
930
|
# subscriptionType: Claude subscription tier. Values include "free", "pro", "max".
|
|
757
931
|
# Default to "max" as RalphX is designed for Max subscription users.
|
|
758
|
-
"subscriptionType":
|
|
932
|
+
"subscriptionType": account.get("subscription_type") or "max",
|
|
759
933
|
# rateLimitTier: API rate limit tier. "default_claude_max_20x" indicates
|
|
760
934
|
# the 20x rate limit multiplier for Max subscribers.
|
|
761
|
-
"rateLimitTier":
|
|
935
|
+
"rateLimitTier": account.get("rate_limit_tier") or "default_claude_max_20x",
|
|
762
936
|
}
|
|
763
937
|
}
|
|
764
938
|
CLAUDE_CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -778,17 +952,18 @@ def swap_credentials_for_loop(
|
|
|
778
952
|
new_refresh = oauth.get("refreshToken")
|
|
779
953
|
|
|
780
954
|
# If refresh token changed, Claude CLI refreshed during execution
|
|
781
|
-
if new_refresh and new_refresh !=
|
|
782
|
-
db.
|
|
783
|
-
|
|
955
|
+
if new_refresh and new_refresh != account["refresh_token"]:
|
|
956
|
+
db.update_account(
|
|
957
|
+
account["id"],
|
|
784
958
|
access_token=oauth.get("accessToken"),
|
|
785
959
|
refresh_token=new_refresh,
|
|
786
960
|
expires_at=int(oauth.get("expiresAt", 0) / 1000),
|
|
787
961
|
)
|
|
788
962
|
auth_log.info(
|
|
789
963
|
"token_captured",
|
|
790
|
-
f"Captured refreshed token from Claude CLI ({
|
|
791
|
-
|
|
964
|
+
f"Captured refreshed token from Claude CLI ({account['email']})",
|
|
965
|
+
email=account["email"],
|
|
966
|
+
account_id=account["id"],
|
|
792
967
|
project_id=project_id,
|
|
793
968
|
)
|
|
794
969
|
except Exception as e:
|
|
@@ -796,7 +971,7 @@ def swap_credentials_for_loop(
|
|
|
796
971
|
auth_log.warning(
|
|
797
972
|
"token_capture_failed",
|
|
798
973
|
f"Failed to capture refreshed token: {e}",
|
|
799
|
-
|
|
974
|
+
email=account.get("email") if account else None,
|
|
800
975
|
project_id=project_id,
|
|
801
976
|
)
|
|
802
977
|
|