loki-mode 6.53.0 → 6.55.0
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.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/bin/postinstall.js +29 -0
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/mcp/__init__.py +1 -1
- package/package.json +11 -2
- package/web-app/Dockerfile +59 -0
- package/web-app/alembic.ini +43 -0
- package/web-app/auth.py +249 -0
- package/web-app/crypto.py +83 -0
- package/web-app/deploy/k8s/purple-lab/configmap.yaml +8 -0
- package/web-app/deploy/k8s/purple-lab/deployment.yaml +69 -0
- package/web-app/deploy/k8s/purple-lab/hpa.yaml +24 -0
- package/web-app/deploy/k8s/purple-lab/ingress.yaml +30 -0
- package/web-app/deploy/k8s/purple-lab/networkpolicy.yaml +82 -0
- package/web-app/deploy/k8s/purple-lab/pdb.yaml +11 -0
- package/web-app/deploy/k8s/purple-lab/postgres.yaml +84 -0
- package/web-app/deploy/k8s/purple-lab/pvc.yaml +10 -0
- package/web-app/deploy/k8s/purple-lab/secret.yaml +13 -0
- package/web-app/deploy/k8s/purple-lab/service.yaml +13 -0
- package/web-app/deploy/k8s/purple-lab/serviceaccount.yaml +7 -0
- package/web-app/dist/assets/{Badge-CnWBUi7C.js → Badge-BDr4DPCT.js} +1 -1
- package/web-app/dist/assets/{Button-5ThWFbkO.js → Button-WBFGRnUr.js} +1 -1
- package/web-app/dist/assets/{Card-CcTmaOCN.js → Card-DzOT34Rr.js} +1 -1
- package/web-app/dist/assets/{HomePage-Dx4Ae0hu.js → HomePage-B8kMCXMB.js} +1 -1
- package/web-app/dist/assets/{LoginPage-CRffqZNo.js → LoginPage-D9lCyiqM.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-B1QZ92yR.js → NotFoundPage-DzeZ0uQ6.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-BVnDGxXk.js → ProjectPage-C-k0iy0i.js} +14 -14
- package/web-app/dist/assets/{ProjectsPage-2Fi6cKB-.js → ProjectsPage-jys_pHzp.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-DOzGoyLv.js → SettingsPage-Cz_RXr82.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-B-f1Gfbg.js → TemplatesPage-COnhb_Wq.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-DrKIbiB8.js → TerminalOutput-CmdEXHHd.js} +1 -1
- package/web-app/dist/assets/{arrow-left-CFG0TEkb.js → arrow-left-DAZzI0L-.js} +1 -1
- package/web-app/dist/assets/{clock-C-GPrW5k.js → clock-BHGf6zSk.js} +1 -1
- package/web-app/dist/assets/{external-link-ujbkNBY4.js → external-link-DLYjfP9j.js} +1 -1
- package/web-app/dist/assets/{index-B8gGcUMo.js → index-B8Eg1YHL.js} +2 -2
- package/web-app/dist/index.html +1 -1
- package/web-app/docker-compose.purple-lab.yml +76 -0
- package/web-app/migrations/env.py +103 -0
- package/web-app/migrations/script.py.mako +25 -0
- package/web-app/migrations/versions/.gitkeep +0 -0
- package/web-app/migrations/versions/001_initial_schema.py +118 -0
- package/web-app/models.py +140 -0
- package/web-app/requirements.txt +27 -0
- package/web-app/server.py +158 -22
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: loki-mode
|
|
|
3
3
|
description: Multi-agent autonomous startup system. Triggers on "Loki Mode". Takes PRD to deployed product with minimal human intervention. Requires --dangerously-skip-permissions flag.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Loki Mode v6.
|
|
6
|
+
# Loki Mode v6.55.0
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
@@ -267,4 +267,4 @@ The following features are documented in skill modules but not yet fully automat
|
|
|
267
267
|
| Quality gates 3-reviewer system | Implemented (v5.35.0) | 5 specialist reviewers in `skills/quality-gates.md`; execution in run.sh |
|
|
268
268
|
| Benchmarks (HumanEval, SWE-bench) | Infrastructure only | Runner scripts and datasets exist in `benchmarks/`; no published results |
|
|
269
269
|
|
|
270
|
-
**v6.
|
|
270
|
+
**v6.55.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
6.
|
|
1
|
+
6.55.0
|
package/bin/postinstall.js
CHANGED
|
@@ -131,6 +131,35 @@ try {
|
|
|
131
131
|
// If npm bin check fails, skip PATH warning silently
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
// Install Python dependencies for Purple Lab (pexpect, watchdog, httpx)
|
|
135
|
+
try {
|
|
136
|
+
const { execSync } = require('child_process');
|
|
137
|
+
const pyDeps = ['pexpect', 'watchdog', 'httpx'];
|
|
138
|
+
// Check if deps are already installed
|
|
139
|
+
const missing = pyDeps.filter(dep => {
|
|
140
|
+
try {
|
|
141
|
+
execSync(`python3 -c "import ${dep}"`, { stdio: 'pipe' });
|
|
142
|
+
return false;
|
|
143
|
+
} catch { return true; }
|
|
144
|
+
});
|
|
145
|
+
if (missing.length > 0) {
|
|
146
|
+
console.log(`Installing Python dependencies: ${missing.join(', ')}...`);
|
|
147
|
+
try {
|
|
148
|
+
execSync(`python3 -m pip install --break-system-packages ${missing.join(' ')}`, { stdio: 'pipe', timeout: 60000 });
|
|
149
|
+
console.log(' [OK] Python dependencies installed');
|
|
150
|
+
} catch {
|
|
151
|
+
try {
|
|
152
|
+
execSync(`python3 -m pip install ${missing.join(' ')}`, { stdio: 'pipe', timeout: 60000 });
|
|
153
|
+
console.log(' [OK] Python dependencies installed');
|
|
154
|
+
} catch {
|
|
155
|
+
console.log(` [WARN] Could not install Python deps. Run: pip install ${missing.join(' ')}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
// Python not available, skip silently
|
|
161
|
+
}
|
|
162
|
+
|
|
134
163
|
console.log('');
|
|
135
164
|
console.log('CLI commands:');
|
|
136
165
|
console.log(' loki start ./prd.md Start with Claude (default)');
|
package/dashboard/__init__.py
CHANGED
package/docs/INSTALLATION.md
CHANGED
package/mcp/__init__.py
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loki-mode",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.55.0",
|
|
4
4
|
"description": "Loki Mode by Autonomi - Multi-agent autonomous startup system for Claude Code, Codex CLI, and Gemini CLI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
@@ -83,7 +83,16 @@
|
|
|
83
83
|
"completions/",
|
|
84
84
|
"src/",
|
|
85
85
|
"web-app/dist/",
|
|
86
|
-
"web-app/server.py"
|
|
86
|
+
"web-app/server.py",
|
|
87
|
+
"web-app/auth.py",
|
|
88
|
+
"web-app/models.py",
|
|
89
|
+
"web-app/crypto.py",
|
|
90
|
+
"web-app/requirements.txt",
|
|
91
|
+
"web-app/alembic.ini",
|
|
92
|
+
"web-app/migrations/",
|
|
93
|
+
"web-app/Dockerfile",
|
|
94
|
+
"web-app/docker-compose.purple-lab.yml",
|
|
95
|
+
"web-app/deploy/"
|
|
87
96
|
],
|
|
88
97
|
"scripts": {
|
|
89
98
|
"postinstall": "node bin/postinstall.js",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Purple Lab Dockerfile
|
|
2
|
+
# Build: docker compose -f docker-compose.purple-lab.yml build
|
|
3
|
+
# Run: docker compose -f docker-compose.purple-lab.yml up
|
|
4
|
+
|
|
5
|
+
# --- Stage 1: Build frontend ---
|
|
6
|
+
FROM node:20-alpine AS frontend-build
|
|
7
|
+
|
|
8
|
+
WORKDIR /build
|
|
9
|
+
COPY package.json package-lock.json* ./
|
|
10
|
+
RUN npm ci --ignore-scripts
|
|
11
|
+
COPY tsconfig*.json vite.config.ts tailwind.config.js postcss.config.js index.html ./
|
|
12
|
+
COPY src/ ./src/
|
|
13
|
+
RUN npm run build
|
|
14
|
+
|
|
15
|
+
# --- Stage 2: Production image ---
|
|
16
|
+
FROM python:3.12-slim
|
|
17
|
+
|
|
18
|
+
LABEL maintainer="Lokesh Mure"
|
|
19
|
+
LABEL description="Purple Lab - Replit-like web UI for Loki Mode"
|
|
20
|
+
|
|
21
|
+
# Install system dependencies
|
|
22
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
23
|
+
bash \
|
|
24
|
+
curl \
|
|
25
|
+
git \
|
|
26
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
27
|
+
|
|
28
|
+
# Create non-root user
|
|
29
|
+
RUN useradd -m -s /bin/bash -u 1000 purplelab
|
|
30
|
+
|
|
31
|
+
WORKDIR /opt/purple-lab
|
|
32
|
+
|
|
33
|
+
# Install Python dependencies
|
|
34
|
+
COPY requirements.txt ./
|
|
35
|
+
RUN pip install --no-cache-dir -r requirements.txt
|
|
36
|
+
|
|
37
|
+
# Copy Python backend
|
|
38
|
+
COPY server.py auth.py models.py ./
|
|
39
|
+
|
|
40
|
+
# Copy built frontend from stage 1
|
|
41
|
+
COPY --from=frontend-build /build/dist/ ./dist/
|
|
42
|
+
|
|
43
|
+
# Copy loki CLI and supporting files from parent context (if needed at runtime)
|
|
44
|
+
# For standalone mode, the server works without loki CLI
|
|
45
|
+
|
|
46
|
+
# Set ownership
|
|
47
|
+
RUN chown -R purplelab:purplelab /opt/purple-lab
|
|
48
|
+
|
|
49
|
+
# Create project data directory
|
|
50
|
+
RUN mkdir -p /projects && chown purplelab:purplelab /projects
|
|
51
|
+
|
|
52
|
+
EXPOSE 57375
|
|
53
|
+
|
|
54
|
+
USER purplelab
|
|
55
|
+
|
|
56
|
+
CMD ["python3", "server.py"]
|
|
57
|
+
|
|
58
|
+
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=3 \
|
|
59
|
+
CMD curl -f http://localhost:57375/health || exit 1
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[alembic]
|
|
2
|
+
script_location = migrations
|
|
3
|
+
prepend_sys_path = .
|
|
4
|
+
|
|
5
|
+
# The URL is overridden at runtime from DATABASE_URL env var.
|
|
6
|
+
# This placeholder is required by alembic but never used directly.
|
|
7
|
+
sqlalchemy.url = driver://user:pass@localhost/dbname
|
|
8
|
+
|
|
9
|
+
[post_write_hooks]
|
|
10
|
+
|
|
11
|
+
[loggers]
|
|
12
|
+
keys = root,sqlalchemy,alembic
|
|
13
|
+
|
|
14
|
+
[handlers]
|
|
15
|
+
keys = console
|
|
16
|
+
|
|
17
|
+
[formatters]
|
|
18
|
+
keys = generic
|
|
19
|
+
|
|
20
|
+
[logger_root]
|
|
21
|
+
level = WARN
|
|
22
|
+
handlers = console
|
|
23
|
+
qualname =
|
|
24
|
+
|
|
25
|
+
[logger_sqlalchemy]
|
|
26
|
+
level = WARN
|
|
27
|
+
handlers =
|
|
28
|
+
qualname = sqlalchemy.engine
|
|
29
|
+
|
|
30
|
+
[logger_alembic]
|
|
31
|
+
level = INFO
|
|
32
|
+
handlers =
|
|
33
|
+
qualname = alembic
|
|
34
|
+
|
|
35
|
+
[handler_console]
|
|
36
|
+
class = StreamHandler
|
|
37
|
+
args = (sys.stderr,)
|
|
38
|
+
level = NOTSET
|
|
39
|
+
formatter = generic
|
|
40
|
+
|
|
41
|
+
[formatter_generic]
|
|
42
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
43
|
+
datefmt = %H:%M:%S
|
package/web-app/auth.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Purple Lab authentication -- JWT + OAuth.
|
|
2
|
+
|
|
3
|
+
When no DATABASE_URL is configured, authentication is completely disabled
|
|
4
|
+
and all endpoints are accessible without tokens (local development mode).
|
|
5
|
+
"""
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import time
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from fastapi import Depends, HTTPException, Request
|
|
14
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from jose import JWTError, jwt
|
|
18
|
+
except ImportError:
|
|
19
|
+
jwt = None # type: ignore[assignment]
|
|
20
|
+
JWTError = Exception # type: ignore[assignment,misc]
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from passlib.context import CryptContext
|
|
24
|
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
25
|
+
except ImportError:
|
|
26
|
+
pwd_context = None # type: ignore[assignment]
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("purple-lab.auth")
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Config from environment variables
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
SECRET_KEY = os.environ.get("PURPLE_LAB_SECRET_KEY", "")
|
|
35
|
+
if not SECRET_KEY:
|
|
36
|
+
SECRET_KEY = secrets.token_hex(32)
|
|
37
|
+
logger.warning(
|
|
38
|
+
"PURPLE_LAB_SECRET_KEY not set -- generated ephemeral key. "
|
|
39
|
+
"Tokens will not survive server restarts. "
|
|
40
|
+
"Set PURPLE_LAB_SECRET_KEY env var for production use."
|
|
41
|
+
)
|
|
42
|
+
ALGORITHM = "HS256"
|
|
43
|
+
ACCESS_TOKEN_EXPIRE_HOURS = 24
|
|
44
|
+
|
|
45
|
+
# OAuth config
|
|
46
|
+
GITHUB_CLIENT_ID = os.environ.get("GITHUB_CLIENT_ID", "")
|
|
47
|
+
GITHUB_CLIENT_SECRET = os.environ.get("GITHUB_CLIENT_SECRET", "")
|
|
48
|
+
GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "")
|
|
49
|
+
GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "")
|
|
50
|
+
|
|
51
|
+
security = HTTPBearer(auto_error=False)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Token helpers
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
|
59
|
+
"""Create a signed JWT access token."""
|
|
60
|
+
if jwt is None:
|
|
61
|
+
raise RuntimeError("python-jose is not installed. Install with: pip install python-jose[cryptography]")
|
|
62
|
+
to_encode = data.copy()
|
|
63
|
+
expire = datetime.utcnow() + (expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS))
|
|
64
|
+
to_encode.update({"exp": expire})
|
|
65
|
+
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def verify_token(token: str) -> Optional[dict]:
|
|
69
|
+
"""Verify and decode a JWT token. Returns None if invalid."""
|
|
70
|
+
if jwt is None:
|
|
71
|
+
return None
|
|
72
|
+
try:
|
|
73
|
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
74
|
+
return payload
|
|
75
|
+
except JWTError:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def hash_password(password: str) -> str:
|
|
80
|
+
"""Hash a password using bcrypt."""
|
|
81
|
+
if pwd_context is None:
|
|
82
|
+
raise RuntimeError("passlib is not installed. Install with: pip install passlib[bcrypt]")
|
|
83
|
+
return pwd_context.hash(password)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
87
|
+
"""Verify a password against a bcrypt hash."""
|
|
88
|
+
if pwd_context is None:
|
|
89
|
+
return False
|
|
90
|
+
return pwd_context.verify(plain_password, hashed_password)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# FastAPI dependencies
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
async def get_current_user(
|
|
98
|
+
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
99
|
+
) -> Optional[dict]:
|
|
100
|
+
"""Get current user from JWT token. Returns None if no auth configured."""
|
|
101
|
+
# If no database is configured, auth is disabled (local development mode)
|
|
102
|
+
from models import async_session_factory
|
|
103
|
+
|
|
104
|
+
if async_session_factory is None:
|
|
105
|
+
return None # Auth disabled, allow all requests
|
|
106
|
+
|
|
107
|
+
if not credentials:
|
|
108
|
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
109
|
+
|
|
110
|
+
payload = verify_token(credentials.credentials)
|
|
111
|
+
if not payload:
|
|
112
|
+
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
|
113
|
+
|
|
114
|
+
return payload
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# OAuth state (CSRF protection)
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
# In-memory store of valid OAuth state tokens. Each entry maps
|
|
122
|
+
# state -> expiry timestamp. Tokens expire after 10 minutes.
|
|
123
|
+
_oauth_states: dict[str, float] = {}
|
|
124
|
+
_OAUTH_STATE_TTL = 600 # 10 minutes
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def generate_oauth_state() -> str:
|
|
128
|
+
"""Generate a cryptographically random state token for OAuth CSRF protection."""
|
|
129
|
+
_purge_expired_states()
|
|
130
|
+
state = secrets.token_urlsafe(32)
|
|
131
|
+
_oauth_states[state] = time.time() + _OAUTH_STATE_TTL
|
|
132
|
+
return state
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def validate_oauth_state(state: str | None) -> bool:
|
|
136
|
+
"""Validate and consume an OAuth state token. Returns False if invalid or expired."""
|
|
137
|
+
if not state:
|
|
138
|
+
return False
|
|
139
|
+
_purge_expired_states()
|
|
140
|
+
expiry = _oauth_states.pop(state, None)
|
|
141
|
+
if expiry is None:
|
|
142
|
+
return False
|
|
143
|
+
return time.time() < expiry
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _purge_expired_states() -> None:
|
|
147
|
+
"""Remove expired state tokens to prevent memory growth."""
|
|
148
|
+
now = time.time()
|
|
149
|
+
expired = [s for s, exp in _oauth_states.items() if now >= exp]
|
|
150
|
+
for s in expired:
|
|
151
|
+
_oauth_states.pop(s, None)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# OAuth handlers
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
async def github_oauth_callback(code: str) -> dict:
|
|
159
|
+
"""Exchange GitHub OAuth code for user info."""
|
|
160
|
+
import httpx
|
|
161
|
+
|
|
162
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
163
|
+
# Exchange code for token
|
|
164
|
+
token_resp = await client.post(
|
|
165
|
+
"https://github.com/login/oauth/access_token",
|
|
166
|
+
json={
|
|
167
|
+
"client_id": GITHUB_CLIENT_ID,
|
|
168
|
+
"client_secret": GITHUB_CLIENT_SECRET,
|
|
169
|
+
"code": code,
|
|
170
|
+
},
|
|
171
|
+
headers={"Accept": "application/json"},
|
|
172
|
+
)
|
|
173
|
+
token_data = token_resp.json()
|
|
174
|
+
access_token = token_data.get("access_token")
|
|
175
|
+
|
|
176
|
+
if not access_token:
|
|
177
|
+
error_desc = token_data.get("error_description", "Unknown error")
|
|
178
|
+
logger.warning("GitHub OAuth token exchange failed: %s", error_desc)
|
|
179
|
+
raise HTTPException(status_code=400, detail=f"Failed to get GitHub access token: {error_desc}")
|
|
180
|
+
|
|
181
|
+
# Get user info
|
|
182
|
+
user_resp = await client.get(
|
|
183
|
+
"https://api.github.com/user",
|
|
184
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
185
|
+
)
|
|
186
|
+
if user_resp.status_code != 200:
|
|
187
|
+
raise HTTPException(status_code=502, detail="Failed to fetch GitHub user info")
|
|
188
|
+
user_data = user_resp.json()
|
|
189
|
+
|
|
190
|
+
# Get primary email
|
|
191
|
+
email_resp = await client.get(
|
|
192
|
+
"https://api.github.com/user/emails",
|
|
193
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
194
|
+
)
|
|
195
|
+
emails = email_resp.json() if email_resp.status_code == 200 else []
|
|
196
|
+
primary_email = next(
|
|
197
|
+
(e["email"] for e in emails if isinstance(e, dict) and e.get("primary")), None
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
"email": primary_email or user_data.get("email"),
|
|
202
|
+
"name": user_data.get("name") or user_data.get("login"),
|
|
203
|
+
"avatar_url": user_data.get("avatar_url"),
|
|
204
|
+
"provider": "github",
|
|
205
|
+
"provider_id": str(user_data["id"]),
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
async def google_oauth_callback(code: str, redirect_uri: str) -> dict:
|
|
210
|
+
"""Exchange Google OAuth code for user info."""
|
|
211
|
+
import httpx
|
|
212
|
+
|
|
213
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
214
|
+
token_resp = await client.post(
|
|
215
|
+
"https://oauth2.googleapis.com/token",
|
|
216
|
+
data={
|
|
217
|
+
"code": code,
|
|
218
|
+
"client_id": GOOGLE_CLIENT_ID,
|
|
219
|
+
"client_secret": GOOGLE_CLIENT_SECRET,
|
|
220
|
+
"redirect_uri": redirect_uri,
|
|
221
|
+
"grant_type": "authorization_code",
|
|
222
|
+
},
|
|
223
|
+
)
|
|
224
|
+
token_data = token_resp.json()
|
|
225
|
+
access_token = token_data.get("access_token")
|
|
226
|
+
|
|
227
|
+
if not access_token:
|
|
228
|
+
error_desc = token_data.get("error_description", "Unknown error")
|
|
229
|
+
logger.warning("Google OAuth token exchange failed: %s", error_desc)
|
|
230
|
+
raise HTTPException(status_code=400, detail=f"Failed to get Google access token: {error_desc}")
|
|
231
|
+
|
|
232
|
+
user_resp = await client.get(
|
|
233
|
+
"https://www.googleapis.com/oauth2/v2/userinfo",
|
|
234
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
235
|
+
)
|
|
236
|
+
if user_resp.status_code != 200:
|
|
237
|
+
raise HTTPException(status_code=502, detail="Failed to fetch Google user info")
|
|
238
|
+
user_data = user_resp.json()
|
|
239
|
+
|
|
240
|
+
if "email" not in user_data:
|
|
241
|
+
raise HTTPException(status_code=400, detail="Google account has no email")
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
"email": user_data["email"],
|
|
245
|
+
"name": user_data.get("name"),
|
|
246
|
+
"avatar_url": user_data.get("picture"),
|
|
247
|
+
"provider": "google",
|
|
248
|
+
"provider_id": str(user_data["id"]),
|
|
249
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Purple Lab secrets encryption -- Fernet symmetric encryption.
|
|
2
|
+
|
|
3
|
+
When PURPLE_LAB_SECRET_KEY is set, secret values are encrypted at rest
|
|
4
|
+
using Fernet (AES-128-CBC with HMAC-SHA256). In local dev mode without
|
|
5
|
+
the env var, secrets are stored in plaintext.
|
|
6
|
+
"""
|
|
7
|
+
import base64
|
|
8
|
+
import hashlib
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("purple-lab.crypto")
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
16
|
+
_HAS_FERNET = True
|
|
17
|
+
except ImportError:
|
|
18
|
+
_HAS_FERNET = False
|
|
19
|
+
InvalidToken = Exception # type: ignore[assignment,misc]
|
|
20
|
+
logger.warning(
|
|
21
|
+
"cryptography package not installed -- secret encryption disabled. "
|
|
22
|
+
"Install with: pip install cryptography"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_secret_key() -> str | None:
|
|
27
|
+
"""Return PURPLE_LAB_SECRET_KEY if explicitly set, else None."""
|
|
28
|
+
return os.environ.get("PURPLE_LAB_SECRET_KEY") or None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _derive_fernet_key(secret: str) -> bytes:
|
|
32
|
+
"""Derive a Fernet-compatible key from an arbitrary secret string.
|
|
33
|
+
|
|
34
|
+
Fernet requires a 32-byte URL-safe base64-encoded key. We derive it
|
|
35
|
+
deterministically from the secret using SHA-256.
|
|
36
|
+
"""
|
|
37
|
+
raw = hashlib.sha256(secret.encode()).digest()
|
|
38
|
+
return base64.urlsafe_b64encode(raw)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def encryption_available() -> bool:
|
|
42
|
+
"""Return True if encryption is both available and configured."""
|
|
43
|
+
return _HAS_FERNET and _get_secret_key() is not None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def encrypt_value(plaintext: str) -> str:
|
|
47
|
+
"""Encrypt a string value using Fernet.
|
|
48
|
+
|
|
49
|
+
Returns the ciphertext as a UTF-8 string. If encryption is not
|
|
50
|
+
available or not configured, returns the plaintext unchanged.
|
|
51
|
+
"""
|
|
52
|
+
secret = _get_secret_key()
|
|
53
|
+
if not _HAS_FERNET or secret is None:
|
|
54
|
+
return plaintext
|
|
55
|
+
try:
|
|
56
|
+
f = Fernet(_derive_fernet_key(secret))
|
|
57
|
+
return f.encrypt(plaintext.encode()).decode()
|
|
58
|
+
except Exception:
|
|
59
|
+
logger.exception("Failed to encrypt value -- storing plaintext")
|
|
60
|
+
return plaintext
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def decrypt_value(ciphertext: str) -> str:
|
|
64
|
+
"""Decrypt a Fernet-encrypted string.
|
|
65
|
+
|
|
66
|
+
Returns the plaintext. If the value was never encrypted (legacy
|
|
67
|
+
plaintext) or decryption fails, returns the original string so that
|
|
68
|
+
existing unencrypted secrets continue to work.
|
|
69
|
+
"""
|
|
70
|
+
secret = _get_secret_key()
|
|
71
|
+
if not _HAS_FERNET or secret is None:
|
|
72
|
+
return ciphertext
|
|
73
|
+
try:
|
|
74
|
+
f = Fernet(_derive_fernet_key(secret))
|
|
75
|
+
return f.decrypt(ciphertext.encode()).decode()
|
|
76
|
+
except InvalidToken:
|
|
77
|
+
# Value is likely plaintext from before encryption was enabled.
|
|
78
|
+
# Return as-is so existing secrets keep working.
|
|
79
|
+
logger.debug("Could not decrypt value -- returning as plaintext (likely legacy)")
|
|
80
|
+
return ciphertext
|
|
81
|
+
except Exception:
|
|
82
|
+
logger.exception("Unexpected decryption error -- returning raw value")
|
|
83
|
+
return ciphertext
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
apiVersion: apps/v1
|
|
2
|
+
kind: Deployment
|
|
3
|
+
metadata:
|
|
4
|
+
name: purple-lab
|
|
5
|
+
labels:
|
|
6
|
+
app: purple-lab
|
|
7
|
+
spec:
|
|
8
|
+
replicas: 2
|
|
9
|
+
selector:
|
|
10
|
+
matchLabels:
|
|
11
|
+
app: purple-lab
|
|
12
|
+
strategy:
|
|
13
|
+
type: RollingUpdate
|
|
14
|
+
rollingUpdate:
|
|
15
|
+
maxSurge: 1
|
|
16
|
+
maxUnavailable: 0
|
|
17
|
+
template:
|
|
18
|
+
metadata:
|
|
19
|
+
labels:
|
|
20
|
+
app: purple-lab
|
|
21
|
+
spec:
|
|
22
|
+
serviceAccountName: purple-lab
|
|
23
|
+
securityContext:
|
|
24
|
+
runAsNonRoot: true
|
|
25
|
+
runAsUser: 1000
|
|
26
|
+
fsGroup: 1000
|
|
27
|
+
containers:
|
|
28
|
+
- name: purple-lab
|
|
29
|
+
image: asklokesh/purple-lab:latest
|
|
30
|
+
imagePullPolicy: Always
|
|
31
|
+
ports:
|
|
32
|
+
- name: http
|
|
33
|
+
containerPort: 57375
|
|
34
|
+
protocol: TCP
|
|
35
|
+
envFrom:
|
|
36
|
+
- configMapRef:
|
|
37
|
+
name: purple-lab-config
|
|
38
|
+
- secretRef:
|
|
39
|
+
name: purple-lab-secrets
|
|
40
|
+
resources:
|
|
41
|
+
requests:
|
|
42
|
+
memory: "256Mi"
|
|
43
|
+
cpu: "250m"
|
|
44
|
+
limits:
|
|
45
|
+
memory: "1Gi"
|
|
46
|
+
cpu: "1000m"
|
|
47
|
+
livenessProbe:
|
|
48
|
+
httpGet:
|
|
49
|
+
path: /health
|
|
50
|
+
port: http
|
|
51
|
+
initialDelaySeconds: 15
|
|
52
|
+
periodSeconds: 10
|
|
53
|
+
timeoutSeconds: 5
|
|
54
|
+
failureThreshold: 3
|
|
55
|
+
readinessProbe:
|
|
56
|
+
httpGet:
|
|
57
|
+
path: /health
|
|
58
|
+
port: http
|
|
59
|
+
initialDelaySeconds: 5
|
|
60
|
+
periodSeconds: 5
|
|
61
|
+
timeoutSeconds: 3
|
|
62
|
+
failureThreshold: 3
|
|
63
|
+
volumeMounts:
|
|
64
|
+
- name: project-storage
|
|
65
|
+
mountPath: /projects
|
|
66
|
+
volumes:
|
|
67
|
+
- name: project-storage
|
|
68
|
+
persistentVolumeClaim:
|
|
69
|
+
claimName: purple-lab-projects
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
apiVersion: autoscaling/v2
|
|
2
|
+
kind: HorizontalPodAutoscaler
|
|
3
|
+
metadata:
|
|
4
|
+
name: purple-lab
|
|
5
|
+
spec:
|
|
6
|
+
scaleTargetRef:
|
|
7
|
+
apiVersion: apps/v1
|
|
8
|
+
kind: Deployment
|
|
9
|
+
name: purple-lab
|
|
10
|
+
minReplicas: 2
|
|
11
|
+
maxReplicas: 10
|
|
12
|
+
metrics:
|
|
13
|
+
- type: Resource
|
|
14
|
+
resource:
|
|
15
|
+
name: cpu
|
|
16
|
+
target:
|
|
17
|
+
type: Utilization
|
|
18
|
+
averageUtilization: 70
|
|
19
|
+
- type: Resource
|
|
20
|
+
resource:
|
|
21
|
+
name: memory
|
|
22
|
+
target:
|
|
23
|
+
type: Utilization
|
|
24
|
+
averageUtilization: 80
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
apiVersion: networking.k8s.io/v1
|
|
2
|
+
kind: Ingress
|
|
3
|
+
metadata:
|
|
4
|
+
name: purple-lab
|
|
5
|
+
annotations:
|
|
6
|
+
nginx.ingress.kubernetes.io/websocket-services: "purple-lab"
|
|
7
|
+
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
|
8
|
+
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
|
9
|
+
nginx.ingress.kubernetes.io/proxy-body-size: "100m"
|
|
10
|
+
nginx.ingress.kubernetes.io/configuration-snippet: |
|
|
11
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
12
|
+
proxy_set_header Connection "upgrade";
|
|
13
|
+
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
|
14
|
+
spec:
|
|
15
|
+
ingressClassName: nginx
|
|
16
|
+
tls:
|
|
17
|
+
- hosts:
|
|
18
|
+
- lab.autonomi.dev
|
|
19
|
+
secretName: purple-lab-tls
|
|
20
|
+
rules:
|
|
21
|
+
- host: lab.autonomi.dev
|
|
22
|
+
http:
|
|
23
|
+
paths:
|
|
24
|
+
- path: /
|
|
25
|
+
pathType: Prefix
|
|
26
|
+
backend:
|
|
27
|
+
service:
|
|
28
|
+
name: purple-lab
|
|
29
|
+
port:
|
|
30
|
+
number: 80
|