diff --git a/CLAUDE.md b/CLAUDE.md index c6914d78..aa48db12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ go run ./cmd/server # Run server (requires Postgres + Redis running) go build -o molecli ./cmd/cli # Build TUI dashboard ./molecli # Run TUI dashboard (requires platform running) ``` -Must run from `platform/` directory (not repo root). Env vars: `DATABASE_URL`, `REDIS_URL`, `PORT`, `PLATFORM_URL` (default `http://host.docker.internal:PORT` — passed to agent containers so they can reach the platform), `SECRETS_ENCRYPTION_KEY` (optional AES-256, 32 bytes), `CONFIGS_DIR` (auto-discovered), `PLUGINS_DIR` (deprecated — plugins are now installed per-workspace via API; the `plugins/` registry at repo root is auto-discovered), `ACTIVITY_RETENTION_DAYS` (default `7`), `ACTIVITY_CLEANUP_INTERVAL_HOURS` (default `6`), `CORS_ORIGINS` (comma-separated, default `http://localhost:3000,http://localhost:3001`), `RATE_LIMIT` (requests/min, default `600`), `WORKSPACE_DIR` (optional — global fallback host path for `/workspace` bind-mount; overridden by per-workspace `workspace_dir` column in DB; if neither is set, each workspace gets an isolated Docker named volume), `AWARENESS_URL` (optional — if set, injected into workspace containers along with a deterministic `AWARENESS_NAMESPACE` derived from workspace ID), `MOLECULE_IN_DOCKER` (optional — set to `1` when the platform itself runs inside Docker so the A2A proxy rewrites `127.0.0.1:` URLs to container hostnames; auto-detected via `/.dockerenv`). +Must run from `platform/` directory (not repo root). Env vars: `DATABASE_URL`, `REDIS_URL`, `PORT`, `PLATFORM_URL` (default `http://host.docker.internal:PORT` — passed to agent containers so they can reach the platform), `SECRETS_ENCRYPTION_KEY` (optional AES-256, 32 bytes), `CONFIGS_DIR` (auto-discovered), `PLUGINS_DIR` (deprecated — plugins are now installed per-workspace via API; the `plugins/` registry at repo root is auto-discovered), `ACTIVITY_RETENTION_DAYS` (default `7`), `ACTIVITY_CLEANUP_INTERVAL_HOURS` (default `6`), `CORS_ORIGINS` (comma-separated, default `http://localhost:3000,http://localhost:3001`), `RATE_LIMIT` (requests/min, default `600`), `WORKSPACE_DIR` (optional — global fallback host path for `/workspace` bind-mount; overridden by per-workspace `workspace_dir` column in DB; if neither is set, each workspace gets an isolated Docker named volume), `AWARENESS_URL` (optional — if set, injected into workspace containers along with a deterministic `AWARENESS_NAMESPACE` derived from workspace ID), `MOLECULE_IN_DOCKER` (optional — set to `1` when the platform itself runs inside Docker so the A2A proxy rewrites `127.0.0.1:` URLs to container hostnames; auto-detected via `/.dockerenv`), `MOLECULE_ENV` (optional — set to `production` to hide the `/admin/workspaces/:id/test-token` E2E helper endpoint; unset or any other value leaves it enabled), `MOLECULE_ENABLE_TEST_TOKENS` (optional — set to `1` to force-enable the test-token endpoint even when `MOLECULE_ENV=production`; intended for staging runs only). **Plugin install safeguards** (bound the cost of a single `POST /workspaces/:id/plugins` install so a slow/malicious source can't tie up a handler): - `PLUGIN_INSTALL_BODY_MAX_BYTES` — max request body size (default `65536` = 64 KiB) @@ -256,6 +256,7 @@ Agents can auto-execute a prompt on startup before any user interaction. Configu | GET | /settings/secrets | secrets.go — list global secrets (keys only, values masked) | | PUT/POST | /settings/secrets | secrets.go — set a global secret {key, value} | | DELETE | /settings/secrets/:key | secrets.go — delete a global secret | +| GET | /admin/workspaces/:id/test-token | admin_test_token.go — mint a fresh bearer token for E2E scripts; 404 unless `MOLECULE_ENV != production` or `MOLECULE_ENABLE_TEST_TOKENS=1` | | GET/POST/DELETE | /admin/secrets[/:key] | secrets.go — legacy aliases for /settings/secrets | | WS | /workspaces/:id/terminal | terminal.go | | POST | /workspaces/:id/expand | team.go | diff --git a/platform/internal/handlers/admin_test_token.go b/platform/internal/handlers/admin_test_token.go new file mode 100644 index 00000000..6a2bb9c6 --- /dev/null +++ b/platform/internal/handlers/admin_test_token.go @@ -0,0 +1,91 @@ +// Package handlers — admin test-token endpoint (follow-up to PR #5, issue #6). +// +// GET /admin/workspaces/:id/test-token mints a fresh workspace auth token for +// E2E scripts, eliminating the register-race in test_comprehensive_e2e.sh. +// The endpoint is DELIBERATELY hidden in production: it returns 404 rather +// than 403 when disabled, so an attacker scanning for admin surfaces can't +// distinguish "route exists, forbidden" from "route doesn't exist." +// +// Enablement contract: +// +// - If MOLECULE_ENABLE_TEST_TOKENS=1 → enabled. +// - Else if MOLECULE_ENV is set and != "production" → enabled. +// - Else → disabled (404). +// +// The fallback to MOLECULE_ENV keeps local dev and CI "just work" without +// requiring every operator to set the enable flag, while forcing production +// deployments (which should set MOLECULE_ENV=production) to stay locked. +package handlers + +import ( + "database/sql" + "log" + "net/http" + "os" + + "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth" + "github.com/gin-gonic/gin" +) + +// TestTokensEnabled reports whether the /admin/workspaces/:id/test-token +// route should respond with tokens. Exported so tests (and operator health +// checks) can share the exact same gating logic. +func TestTokensEnabled() bool { + if os.Getenv("MOLECULE_ENABLE_TEST_TOKENS") == "1" { + return true + } + // Empty MOLECULE_ENV defaults to enabled — local dev runs don't set it. + // Production deployments MUST set MOLECULE_ENV=production to lock this. + return os.Getenv("MOLECULE_ENV") != "production" +} + +// AdminTestTokenHandler mints a fresh token for an existing workspace. +type AdminTestTokenHandler struct{} + +func NewAdminTestTokenHandler() *AdminTestTokenHandler { + return &AdminTestTokenHandler{} +} + +// GetTestToken handles GET /admin/workspaces/:id/test-token. +func (h *AdminTestTokenHandler) GetTestToken(c *gin.Context) { + if !TestTokensEnabled() { + // 404 (not 403) — hide the route's existence entirely in prod. + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + workspaceID := c.Param("id") + if workspaceID == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + // Confirm the workspace exists — a missing workspace also 404s so we + // can't be used to probe for arbitrary IDs. + var exists string + err := db.DB.QueryRowContext(c.Request.Context(), + `SELECT id FROM workspaces WHERE id = $1`, workspaceID).Scan(&exists) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "lookup failed"}) + return + } + + token, err := wsauth.IssueToken(c.Request.Context(), db.DB, workspaceID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "token issue failed"}) + return + } + + // INFO log — never include the token itself. + log.Printf("admin: issued test token for workspace %s", workspaceID) + + c.JSON(http.StatusOK, gin.H{ + "auth_token": token, + "workspace_id": workspaceID, + }) +} diff --git a/platform/internal/handlers/admin_test_token_test.go b/platform/internal/handlers/admin_test_token_test.go new file mode 100644 index 00000000..a6d537a1 --- /dev/null +++ b/platform/internal/handlers/admin_test_token_test.go @@ -0,0 +1,131 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth" + "github.com/gin-gonic/gin" +) + +func newTestTokenRequest(workspaceID string) (*httptest.ResponseRecorder, *gin.Context) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: workspaceID}} + c.Request = httptest.NewRequest("GET", "/admin/workspaces/"+workspaceID+"/test-token", nil) + return w, c +} + +func TestAdminTestToken_HiddenInProduction(t *testing.T) { + setupTestDB(t) + t.Setenv("MOLECULE_ENV", "production") + t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "") + + h := NewAdminTestTokenHandler() + w, c := newTestTokenRequest("ws-1") + h.GetTestToken(c) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 in production, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestAdminTestToken_EnabledViaFlagEvenInProd(t *testing.T) { + mock := setupTestDB(t) + t.Setenv("MOLECULE_ENV", "production") + t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1") + + mock.ExpectQuery("SELECT id FROM workspaces WHERE id ="). + WithArgs("ws-1"). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1")) + mock.ExpectExec("INSERT INTO workspace_auth_tokens"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + h := NewAdminTestTokenHandler() + w, c := newTestTokenRequest("ws-1") + h.GetTestToken(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestAdminTestToken_WorkspaceNotFound(t *testing.T) { + mock := setupTestDB(t) + t.Setenv("MOLECULE_ENV", "development") + + mock.ExpectQuery("SELECT id FROM workspaces WHERE id ="). + WithArgs("missing"). + WillReturnError(sqlErrNoRows()) + + h := NewAdminTestTokenHandler() + w, c := newTestTokenRequest("missing") + h.GetTestToken(c) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 for missing workspace, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestAdminTestToken_HappyPath_TokenValidates(t *testing.T) { + mock := setupTestDB(t) + t.Setenv("MOLECULE_ENV", "development") + + mock.ExpectQuery("SELECT id FROM workspaces WHERE id ="). + WithArgs("ws-1"). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1")) + + // Capture the hash inserted by IssueToken so we can replay it on Validate. + var capturedHash []byte + mock.ExpectExec("INSERT INTO workspace_auth_tokens"). + WithArgs("ws-1", sqlmock.AnyArg(), sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(0, 1)) + + h := NewAdminTestTokenHandler() + w, c := newTestTokenRequest("ws-1") + h.GetTestToken(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp struct { + AuthToken string `json:"auth_token"` + WorkspaceID string `json:"workspace_id"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("bad json: %v", err) + } + if resp.AuthToken == "" { + t.Fatal("expected non-empty auth_token") + } + if resp.WorkspaceID != "ws-1" { + t.Errorf("expected workspace_id ws-1, got %q", resp.WorkspaceID) + } + if len(resp.AuthToken) < 32 { + t.Errorf("token looks too short: %d chars", len(resp.AuthToken)) + } + + // Now simulate ValidateToken lookup using the same DB — prove the token + // can be validated by feeding its sha256 back through ExpectedArgs. + // (We stub the SELECT rather than re-reading capturedHash since sqlmock + // doesn't capture live args; the important invariant is that the issued + // token passes ValidateToken given a matching hash row exists.) + _ = capturedHash + mock.ExpectQuery("SELECT id, workspace_id\\s+FROM workspace_auth_tokens"). + WithArgs(sqlmock.AnyArg()). + WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id"}).AddRow("tok-1", "ws-1")) + mock.ExpectExec("UPDATE workspace_auth_tokens SET last_used_at"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + if err := wsauth.ValidateToken(c.Request.Context(), db.DB, "ws-1", resp.AuthToken); err != nil { + t.Errorf("issued token failed to validate: %v", err) + } +} + +func sqlErrNoRows() error { return sql.ErrNoRows } diff --git a/platform/internal/router/router.go b/platform/internal/router/router.go index bf3bd583..6bf08b3a 100644 --- a/platform/internal/router/router.go +++ b/platform/internal/router/router.go @@ -216,6 +216,14 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi adminAuth.DELETE("/admin/secrets/:key", sechGlobal.DeleteGlobal) } + // Admin — test token minting (issue #6). Hidden in production via TestTokensEnabled(). + // Registered at root (not inside AdminAuth) because it is itself the bootstrap for + // acquiring a token, and it's gated on MOLECULE_ENV / MOLECULE_ENABLE_TEST_TOKENS. + { + tokh := handlers.NewAdminTestTokenHandler() + r.GET("/admin/workspaces/:id/test-token", tokh.GetTestToken) + } + // Terminal — shares Docker client with provisioner var dockerCli *client.Client if prov != nil { diff --git a/tests/e2e/_lib.sh b/tests/e2e/_lib.sh index ff3bf996..8999aad8 100755 --- a/tests/e2e/_lib.sh +++ b/tests/e2e/_lib.sh @@ -19,6 +19,32 @@ e2e_extract_token() { # Delete every workspace currently on the platform. Use at the top of a # script so count-based assertions are reproducible across runs. +# Mint a fresh workspace auth token via the admin endpoint (issue #6). +# Use this INSTEAD of racing /registry/register from the test harness — +# GET /admin/workspaces/:id/test-token is deterministic and gated by +# MOLECULE_ENV (off in production, on in dev / CI). +# +# Usage: +# TOKEN=$(e2e_mint_test_token "$workspace_id") || exit 1 +e2e_mint_test_token() { + local wid="$1" + if [ -z "$wid" ]; then + echo "e2e_mint_test_token: workspace id required" >&2 + return 2 + fi + local body + body=$(curl -s -w "\n%{http_code}" "$BASE/admin/workspaces/$wid/test-token") + local code + code=$(printf '%s' "$body" | tail -n1) + local json + json=$(printf '%s' "$body" | sed '$d') + if [ "$code" != "200" ]; then + echo "e2e_mint_test_token: got HTTP $code (is MOLECULE_ENV!=production?)" >&2 + return 1 + fi + printf '%s' "$json" | python3 -c "import json,sys; print(json.load(sys.stdin)['auth_token'])" +} + e2e_cleanup_all_workspaces() { for _wid in $(curl -s "$BASE/workspaces" | python3 -c "import json,sys try: