diff --git a/packages/api/alayaos_api/routers/health.py b/packages/api/alayaos_api/routers/health.py index 45e1bb8..dfcbb45 100644 --- a/packages/api/alayaos_api/routers/health.py +++ b/packages/api/alayaos_api/routers/health.py @@ -1,5 +1,6 @@ """Health check endpoints.""" +from functools import lru_cache from typing import Annotated from fastapi import APIRouter, Depends @@ -7,10 +8,16 @@ from sqlalchemy.ext.asyncio import AsyncSession from alayaos_api.deps import get_session +from alayaos_core.config import Settings router = APIRouter(tags=["health"]) +@lru_cache +def get_settings() -> Settings: + return Settings() + + @router.get("/health/live") async def health_live(): return {"status": "ok"} @@ -18,6 +25,7 @@ async def health_live(): @router.get("/health/ready") async def health_ready(session: Annotated[AsyncSession, Depends(get_session)]): + settings = get_settings() checks = {} # Database @@ -57,4 +65,6 @@ async def health_ready(session: Annotated[AsyncSession, Depends(get_session)]): ok_checks = [v for v in checks.values() if v != "unavailable"] overall = "ok" if ok_checks and all(v == "ok" for v in ok_checks) else "degraded" - return {"status": overall, "checks": checks, "first_run": first_run} + if settings.HEALTH_READY_VERBOSE: + return {"status": overall, "checks": checks, "first_run": first_run} + return {"status": overall} diff --git a/packages/api/tests/test_routers_health.py b/packages/api/tests/test_routers_health.py index 6a114f8..e7a157d 100644 --- a/packages/api/tests/test_routers_health.py +++ b/packages/api/tests/test_routers_health.py @@ -1,13 +1,22 @@ """Tests for health endpoints.""" +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock from fastapi import FastAPI from fastapi.testclient import TestClient +from alayaos_api.routers import health from alayaos_api.routers.health import router +def make_scalar_result(value): + result = MagicMock() + result.scalar_one.return_value = value + result.scalar_one_or_none.return_value = value + return result + + def make_test_app(): app = FastAPI() @@ -43,3 +52,72 @@ def test_health_live_no_auth_required() -> None: client = TestClient(app) response = client.get("/health/live") assert response.status_code == 200 + + +def test_health_ready_redacts_details_by_default(monkeypatch) -> None: + monkeypatch.delenv("ALAYA_HEALTH_READY_VERBOSE", raising=False) + health.get_settings.cache_clear() + app, session_mock = make_test_app() + session_mock.execute.side_effect = [ + MagicMock(), + make_scalar_result("0004"), + make_scalar_result(1), + make_scalar_result(0), + ] + + client = TestClient(app) + response = client.get("/health/ready") + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +def test_health_ready_includes_checks_when_verbose(monkeypatch) -> None: + monkeypatch.setenv("ALAYA_HEALTH_READY_VERBOSE", "true") + health.get_settings.cache_clear() + app, session_mock = make_test_app() + session_mock.execute.side_effect = [ + MagicMock(), + make_scalar_result("0004"), + make_scalar_result(1), + make_scalar_result(0), + ] + + client = TestClient(app) + response = client.get("/health/ready") + + assert response.status_code == 200 + body = response.json() + assert body["status"] == "ok" + assert body["checks"]["database"] == "ok" + assert body["checks"]["migrations"] == "ok" + assert body["checks"]["seeds"] == "ok" + assert body["first_run"] is True + + +def test_health_ready_uses_cached_settings(monkeypatch) -> None: + settings_factory = MagicMock(return_value=SimpleNamespace(HEALTH_READY_VERBOSE=False)) + monkeypatch.setattr(health, "Settings", settings_factory) + health.get_settings.cache_clear() + + app, session_mock = make_test_app() + session_mock.execute.side_effect = [ + MagicMock(), + make_scalar_result("0004"), + make_scalar_result(1), + make_scalar_result(0), + MagicMock(), + make_scalar_result("0004"), + make_scalar_result(1), + make_scalar_result(0), + ] + + client = TestClient(app) + first = client.get("/health/ready") + second = client.get("/health/ready") + + assert first.status_code == 200 + assert second.status_code == 200 + assert settings_factory.call_count == 1 + + health.get_settings.cache_clear() diff --git a/packages/cli-go/internal/cmd/setup.go b/packages/cli-go/internal/cmd/setup.go index be8d200..5a76244 100644 --- a/packages/cli-go/internal/cmd/setup.go +++ b/packages/cli-go/internal/cmd/setup.go @@ -13,6 +13,9 @@ import ( ) var setupProfile string +var setupShowSecret bool + +const storedKeyPlaceholder = "" var setupCmd = &cobra.Command{ Use: "setup", @@ -49,20 +52,10 @@ var setupAgentCmd = &cobra.Command{ fmt.Println("API key created and stored.") apiKey = newKey } - switch strings.ToLower(setupProfile) { - case "claude-code": - fmt.Printf("# Add to .claude/settings.json:\n") - fmt.Printf(`{"mcpServers":{"alaya":{"command":"alaya","args":["mcp"],"env":{"ALAYA_SERVER_URL":"%s","ALAYA_API_KEY":"%s"}}}}`, baseURL, apiKey) - fmt.Println() - case "codex": - fmt.Printf("export ALAYA_SERVER_URL=%s\nexport ALAYA_API_KEY=%s\n", baseURL, apiKey) - case "cursor": - fmt.Printf("# Add to .cursor/mcp.json:\n") - fmt.Printf(`{"mcpServers":{"alaya":{"command":"alaya","args":["mcp"],"env":{"ALAYA_SERVER_URL":"%s","ALAYA_API_KEY":"%s"}}}}`, baseURL, apiKey) - fmt.Println() - default: - fmt.Printf("ALAYA_SERVER_URL=%s\nALAYA_API_KEY=%s\nALAYA_API_BASE=%s/api/v1\n", baseURL, apiKey, baseURL) + if err := warnShowSecret(cmd, setupShowSecret); err != nil { + return err } + fmt.Print(renderAgentSetup(setupProfile, baseURL, apiKey, setupShowSecret)) return nil }, } @@ -71,6 +64,33 @@ func init() { rootCmd.AddCommand(setupCmd) setupCmd.AddCommand(setupAgentCmd) setupAgentCmd.Flags().StringVar(&setupProfile, "profile", "generic", "Agent profile (claude-code|codex|cursor|generic)") + setupAgentCmd.Flags().BoolVar(&setupShowSecret, "show-secret", false, "Print the API key in generated output") +} + +func renderAgentSetup(profile, baseURL, apiKey string, showSecret bool) string { + renderedKey := storedKeyPlaceholder + if showSecret { + renderedKey = apiKey + } + + switch strings.ToLower(profile) { + case "claude-code": + return fmt.Sprintf("# Add to .claude/settings.json:\n{\"mcpServers\":{\"alaya\":{\"command\":\"alaya\",\"args\":[\"mcp\"],\"env\":{\"ALAYA_SERVER_URL\":\"%s\",\"ALAYA_API_KEY\":\"%s\"}}}}\n", baseURL, renderedKey) + case "codex": + return fmt.Sprintf("export ALAYA_SERVER_URL=%s\nexport ALAYA_API_KEY=%s\n", baseURL, renderedKey) + case "cursor": + return fmt.Sprintf("# Add to .cursor/mcp.json:\n{\"mcpServers\":{\"alaya\":{\"command\":\"alaya\",\"args\":[\"mcp\"],\"env\":{\"ALAYA_SERVER_URL\":\"%s\",\"ALAYA_API_KEY\":\"%s\"}}}}\n", baseURL, renderedKey) + default: + return fmt.Sprintf("ALAYA_SERVER_URL=%s\nALAYA_API_KEY=%s\nALAYA_API_BASE=%s/api/v1\n", baseURL, renderedKey, baseURL) + } +} + +func warnShowSecret(cmd *cobra.Command, showSecret bool) error { + if !showSecret { + return nil + } + _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Warning: --show-secret prints the API key to stdout. Use only in a trusted terminal.") + return err } // createAPIKeyViaBootstrap calls POST /api-keys using the bootstrap key and returns the raw key. @@ -84,13 +104,20 @@ func createAPIKeyViaBootstrap(baseURL, bootstrapKey string) (string, error) { return "", err } var resp struct { + Data struct { + RawKey string `json:"raw_key"` + } `json:"data"` RawKey string `json:"raw_key"` } if err := json.Unmarshal(data, &resp); err != nil { return "", fmt.Errorf("parse key response: %w", err) } - if resp.RawKey == "" { + rawKey := resp.Data.RawKey + if rawKey == "" { + rawKey = resp.RawKey + } + if rawKey == "" { return "", fmt.Errorf("server did not return raw_key in response") } - return resp.RawKey, nil + return rawKey, nil } diff --git a/packages/cli-go/internal/cmd/setup_test.go b/packages/cli-go/internal/cmd/setup_test.go index 0c50f2b..4c3febc 100644 --- a/packages/cli-go/internal/cmd/setup_test.go +++ b/packages/cli-go/internal/cmd/setup_test.go @@ -1,10 +1,14 @@ package cmd import ( + "bytes" "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" + + "github.com/spf13/cobra" ) func TestCreateAPIKeyViaBootstrap_Success(t *testing.T) { @@ -18,7 +22,9 @@ func TestCreateAPIKeyViaBootstrap_Success(t *testing.T) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ - "raw_key": "ak_newly_created_key", + "data": map[string]interface{}{ + "raw_key": "ak_newly_created_key", + }, }) })) defer ts.Close() @@ -49,9 +55,10 @@ func TestCreateAPIKeyViaBootstrap_MissingRawKey(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - // Response missing raw_key field json.NewEncoder(w).Encode(map[string]interface{}{ - "id": "key-uuid", + "data": map[string]interface{}{ + "id": "key-uuid", + }, }) })) defer ts.Close() @@ -61,3 +68,51 @@ func TestCreateAPIKeyViaBootstrap_MissingRawKey(t *testing.T) { t.Fatal("expected error when raw_key is missing from response") } } + +func TestRenderAgentSetup_RedactsSecretByDefault(t *testing.T) { + output := renderAgentSetup("generic", "https://alaya.example", "ak_super_secret", false) + + if !strings.Contains(output, "ALAYA_SERVER_URL=https://alaya.example") { + t.Fatalf("expected server URL in output, got %q", output) + } + if !strings.Contains(output, "") { + t.Fatalf("expected redaction placeholder in output, got %q", output) + } + if strings.Contains(output, "ak_super_secret") { + t.Fatalf("expected secret to be redacted, got %q", output) + } +} + +func TestRenderAgentSetup_IncludesSecretWhenOptedIn(t *testing.T) { + output := renderAgentSetup("codex", "https://alaya.example", "ak_super_secret", true) + + if !strings.Contains(output, "ALAYA_API_KEY=ak_super_secret") { + t.Fatalf("expected secret in output, got %q", output) + } +} + +func TestWarnShowSecret_PrintsToStderr(t *testing.T) { + cmd := &cobra.Command{} + var stderr bytes.Buffer + cmd.SetErr(&stderr) + + if err := warnShowSecret(cmd, true); err != nil { + t.Fatalf("warnShowSecret() error: %v", err) + } + if !strings.Contains(stderr.String(), "--show-secret prints the API key to stdout") { + t.Fatalf("expected warning on stderr, got %q", stderr.String()) + } +} + +func TestWarnShowSecret_SkipsWhenDisabled(t *testing.T) { + cmd := &cobra.Command{} + var stderr bytes.Buffer + cmd.SetErr(&stderr) + + if err := warnShowSecret(cmd, false); err != nil { + t.Fatalf("warnShowSecret() error: %v", err) + } + if stderr.Len() != 0 { + t.Fatalf("expected no stderr output, got %q", stderr.String()) + } +} diff --git a/packages/core/alayaos_core/config.py b/packages/core/alayaos_core/config.py index 6ad8007..a47cbaa 100644 --- a/packages/core/alayaos_core/config.py +++ b/packages/core/alayaos_core/config.py @@ -16,6 +16,7 @@ class Settings(BaseSettings): DB_POOL_TIMEOUT: int = 30 DB_ECHO: bool = False LOG_LEVEL: str = "INFO" + HEALTH_READY_VERBOSE: bool = False # LLM Provider EXTRACTION_LLM_PROVIDER: str = "anthropic" # anthropic|openai|ollama|vllm