diff --git a/.github/workflows/parsar-server-release.yml b/.github/workflows/parsar-server-release.yml index 4db71e4..8baca07 100644 --- a/.github/workflows/parsar-server-release.yml +++ b/.github/workflows/parsar-server-release.yml @@ -1,15 +1,16 @@ -# Build + push the parsar-server image to GitHub Container Registry. +# Build the parsar-server image in CI and push releases to GHCR. # # This is the Phase 1 deliverable's "one command" enabler: docker-compose.local.yml # (and the Phase 2 self-host compose) pull ghcr.io//parsar-server by # default. Same image, two compose profiles — profile not fork. # # Triggers: +# - Pull request touching server/web/build inputs -> build only, no push # - Push to main touching server/web/build inputs -> :latest + : # - Tag push matching `parsar-server-v*` -> : -# - Manual workflow_dispatch +# - Manual workflow_dispatch -> build + push # -# Output: ghcr.io/${{ github.repository_owner }}/parsar-server: +# Output: ghcr.io//parsar-server: # - latest on default-branch pushes # - on every build # - on tag pushes (e.g. parsar-server-v0.1.0) @@ -18,7 +19,7 @@ # `images:` field, or just override PARSAR_SERVER_IMAGE in the compose .env # and never run this workflow. -name: parsar-server image release +name: parsar-server image on: push: @@ -36,11 +37,32 @@ on: - "go.mod" - "go.sum" - "go.work" + - "package.json" + - "pnpm-workspace.yaml" - "pnpm-lock.yaml" + - "docs/openapi/openapi.yaml" + - ".dockerignore" - "Dockerfile" - ".github/workflows/parsar-server-release.yml" tags: - "parsar-server-v*" + pull_request: + paths: + - "server/**" + - "internal/**" + - "apps/web/**" + - "apps/parsar-daemon/**" + - "packages/**" + - "go.mod" + - "go.sum" + - "go.work" + - "package.json" + - "pnpm-workspace.yaml" + - "pnpm-lock.yaml" + - "docs/openapi/openapi.yaml" + - ".dockerignore" + - "Dockerfile" + - ".github/workflows/parsar-server-release.yml" workflow_dispatch: permissions: @@ -60,7 +82,13 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 + - name: Compute lowercase GHCR image name + id: image + shell: bash + run: echo "name=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/parsar-server" >> "$GITHUB_OUTPUT" + - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' uses: docker/login-action@v4 with: registry: ghcr.io @@ -71,20 +99,21 @@ jobs: id: meta uses: docker/metadata-action@v6 with: - images: ghcr.io/${{ github.repository_owner }}/parsar-server + images: ${{ steps.image.outputs.name }} tags: | type=ref,event=branch + type=ref,event=pr type=ref,event=tag type=sha,format=short type=raw,value=latest,enable={{is_default_branch}} - - name: Build and push + - name: Build image uses: docker/build-push-action@v7 with: context: . file: Dockerfile platforms: linux/amd64,linux/arm64 - push: true + push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha diff --git a/.github/workflows/sandbox-image-release.yml b/.github/workflows/sandbox-image-release.yml index a8be62d..4357140 100644 --- a/.github/workflows/sandbox-image-release.yml +++ b/.github/workflows/sandbox-image-release.yml @@ -17,7 +17,7 @@ # - Tag push matching `sandbox-v*` (release tags) # - Manual workflow_dispatch # -# Output: ghcr.io/${{ github.repository_owner }}/parsar-sandbox: +# Output: ghcr.io//parsar-sandbox: # - `latest` on main pushes # - `` short-SHA tag on every build # - `` on tag pushes (e.g. `sandbox-v0.0.1`) @@ -62,6 +62,11 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 + - name: Compute lowercase GHCR image name + id: image + shell: bash + run: echo "name=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/parsar-sandbox" >> "$GITHUB_OUTPUT" + - name: Log in to GitHub Container Registry uses: docker/login-action@v4 with: @@ -73,7 +78,7 @@ jobs: id: meta uses: docker/metadata-action@v6 with: - images: ghcr.io/${{ github.repository_owner }}/parsar-sandbox + images: ${{ steps.image.outputs.name }} # latest on main, sha for every push, release tag on tag push tags: | type=ref,event=branch diff --git a/Dockerfile b/Dockerfile index b39aeee..7728886 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,7 +38,7 @@ ARG NODE_VERSION=22-alpine ARG GO_VERSION=1.25-bookworm ARG RUNTIME_BASE=debian:bookworm-slim -FROM node:${NODE_VERSION} AS web-builder +FROM --platform=$BUILDPLATFORM node:${NODE_VERSION} AS web-builder ENV PNPM_HOME=/pnpm ENV PATH=/pnpm:$PATH RUN corepack enable && corepack prepare pnpm@10.30.3 --activate @@ -74,7 +74,9 @@ RUN pnpm --filter @parsar/web build # minimal runtime. trimpath strips build-host file paths from the # binary (defence-in-depth against operator info leaks). ############################################################################### -FROM golang:${GO_VERSION} AS go-builder +FROM --platform=$BUILDPLATFORM golang:${GO_VERSION} AS go-builder +ARG TARGETOS +ARG TARGETARCH WORKDIR /src # Module graph first, source second — keeps `go mod download` cacheable. @@ -92,11 +94,11 @@ COPY server ./server # combined they shave ~25% off binary size with no runtime cost. RUN cd server \ && mkdir -p /out \ - && CGO_ENABLED=0 GOFLAGS=-trimpath go build -ldflags="-s -w" \ + && CGO_ENABLED=0 GOOS="$TARGETOS" GOARCH="$TARGETARCH" GOFLAGS=-trimpath go build -ldflags="-s -w" \ -o /out/parsar-server ./cmd/server \ - && CGO_ENABLED=0 GOFLAGS=-trimpath go build -ldflags="-s -w" \ + && CGO_ENABLED=0 GOOS="$TARGETOS" GOARCH="$TARGETARCH" GOFLAGS=-trimpath go build -ldflags="-s -w" \ -o /out/parsar-migrate ./cmd/migrate \ - && CGO_ENABLED=0 GOFLAGS=-trimpath go build -ldflags="-s -w" \ + && CGO_ENABLED=0 GOOS="$TARGETOS" GOARCH="$TARGETARCH" GOFLAGS=-trimpath go build -ldflags="-s -w" \ -o /out/parsar-bootstrap ./cmd/parsar-bootstrap # parsar-daemon source (root module, no separate go.mod). Copied after the @@ -127,7 +129,7 @@ RUN mkdir -p /out/daemon \ # - The opencode local runner may shell out (rg, basic core utils); # keeping a real userland avoids surprises. ############################################################################### -FROM ${RUNTIME_BASE} AS runtime +FROM --platform=$TARGETPLATFORM ${RUNTIME_BASE} AS runtime ARG PARSAR_USER=parsar ARG PARSAR_UID=10001 diff --git a/INSTALL.md b/INSTALL.md index 8f04702..9b25079 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,158 +1,85 @@ -# INSTALL — Parsar Local Quickstart +# INSTALL - Parsar Local Quickstart -> **Audience:** developers and AI coding agents (Claude Code, Cursor, Codex, etc.) who want to run Parsar on their own machine or evaluate it. -> **What you get:** a working Parsar in a few minutes (mock login, no Feishu or secrets required), with your local Claude Code / OpenCode / Codex paired in as an **online device** — the entire flow is "copy one command from the browser → paste in a terminal → device online". -> **Not covered here:** self-hosting / production deploys (real Feishu OIDC, custom ports and secrets, bootstrap tokens) live on a separate path — see `deploy/compose/compose.selfhost.yml` and `docs/deploy/`. +## One-command install ---- - -## Overview - -``` -clone → (pull/build image) → docker compose up → log in via browser → pair device → device online -``` - -The local stack is all-in-one: `postgres` + a one-shot `init` (migrate + first owner) + `parsar-server` (mock login). **Profile not fork:** it uses the **same image / migrations / SPA / install.sh** as the self-host edition; only the compose file and env values differ. - ---- - -## 0 · Prerequisites +Parsar can be installed without cloning the repository. The installer writes +all generated config, secrets, database files, and runtime state under +`~/.parsar/`. ```bash -docker compose version # v2+ required -claude --version || opencode --version || codex --version # at least one, and logged in +curl -fsSL https://raw.githubusercontent.com/MiniMax-AI-Dev/parsar/main/install.sh | bash ``` -- **Docker** installed and running (on macOS open Docker Desktop first so `docker info` shows the server section). -- At least one Agent CLI (Claude Code / OpenCode / Codex) installed and **logged in** on this machine. The daemon does not embed any model login of its own; it reuses this CLI's login state and subscription, and it can see the real repositories on your host. +When it finishes, open: ---- - -## 1 · Clone the repo - -```bash -git clone parsar -cd parsar -# The local-edition deliverable currently lives on feature/deliverables-design; -# skip this step once it lands on the main branch and use the default branch. -git checkout feature/deliverables-design +```text +http://127.0.0.1:18080 ``` ---- +Create the first owner account in the web setup flow. The first registered +user is the administrator. -## 2 · Pick an image and set env vars +## Requirements -By default the next step's `docker compose` pulls a prebuilt image from GHCR — no manual pull needed: +- Docker Engine with Docker Compose v2. +- Linux for Docker-managed agent sandboxes. Non-Linux hosts can still start + the web control plane, but managed Docker sandboxes are disabled by default. -```bash -# Default ports are 18080 / 15432; change these if they clash (e.g. 18088 / 15488) -export PARSAR_LOCAL_PORT=18080 -export PARSAR_PG_PORT=15432 -``` +## What The Installer Does -> **Current status (remove this note once the image is published to GHCR):** -> The key that makes device pairing zero-config offline — the **four platform daemon binaries baked into the image** — has not yet been published alongside the image on GHCR. Until it is, pulling the GHCR image directly gives you an image **without** the embedded daemon, so **step 4 will fail (404) when it tries to download the daemon**. In the meantime, build locally as a fallback: -> ```bash -> make docker-build PARSAR_IMAGE=parsar PARSAR_IMAGE_TAG=local # first build takes ~5–10 min -> export PARSAR_SERVER_IMAGE=parsar:local -> ``` -> Verify the built image really contains the daemon: -> ```bash -> docker run --rm --entrypoint ls parsar:local /usr/local/share/parsar/daemon -> # Expected: four files parsar-daemon-{darwin,linux}-{amd64,arm64} -> ``` -> Once the image is published to GHCR this note (and the local-build step above) go away — just skip to step 3 and use the default image. +The script: ---- +- creates `~/.parsar/compose.yml` +- creates `~/.parsar/.env` +- generates and persists `PARSAR_MASTER_KEY` +- generates and persists the Postgres password +- starts Postgres, runs migrations, and starts `parsar-server` +- leaves Feishu, Slack, and Discord integrations disabled unless explicitly + enabled in `~/.parsar/.env` -## 3 · Bring the local stack up with one command +It does not write runtime state into the repository or the current working +directory. -```bash -docker compose -f docker-compose.local.yml up -d -``` +## Common Options -The first run pulls / uses the `parsar-server` image, runs the database migrations, and provisions the first workspace automatically (owner = the mock identity `admin@example.com`). +```bash +# Use a different web port. +curl -fsSL https://raw.githubusercontent.com/MiniMax-AI-Dev/parsar/main/install.sh \ + | bash -s -- --port 18088 -**Expected:** three containers; `parsar-local-server` and `parsar-local-postgres` become healthy within ~15 seconds; `parsar-local-init` exits after it finishes. +# Use a pinned image. +curl -fsSL https://raw.githubusercontent.com/MiniMax-AI-Dev/parsar/main/install.sh \ + | bash -s -- --image ghcr.io/minimax-ai-dev/parsar-server:v0.1.0 -**Verify:** -```bash -docker compose -f docker-compose.local.yml ps -docker inspect parsar-local-init --format 'init exit={{.State.ExitCode}}' -# Expected: 0; a repeated `up` still shows 0 (parsar-bootstrap exits 2 when -# already bootstrapped, and compose collapses that back to 0 via `|| [ $? -eq 2 ]`) +# Start without Docker-managed agent sandboxes. +curl -fsSL https://raw.githubusercontent.com/MiniMax-AI-Dev/parsar/main/install.sh \ + | bash -s -- --no-sandbox ``` -Quick end-to-end sanity check from the CLI (30 seconds before you touch the browser): +For local development from a checkout: + ```bash -B="http://127.0.0.1:${PARSAR_LOCAL_PORT}" -for p in /healthz /api/v1/health / ; do - printf '%-18s -> %s\n' "$p" "$(curl -fsS -o /dev/null -w '%{http_code}' "$B$p")" -done -curl -fsS "$B/api/v1/bootstrap/status"; echo -``` -**Expected:** +make docker-build +./install.sh --image parsar:dev ``` -/healthz -> 200 -/api/v1/health -> 200 -/ -> 200 -{"needed":false,"has_owners":true,"owner_count":1, ...} -``` - ---- - -## 4 · Browser E2E acceptance (north star: pair your device) -1. Open **http://127.0.0.1:18080** in your browser (use your own port if you changed it). -2. Click **Log in** (the button may read "Feishu login"; mock mode needs no username / password, it goes straight through) — identity `admin@example.com`, landing on `Local Workspace`. -3. Go to **Devices / runtime management** → click **"Pair new device"** → enter a device name → click **"Generate connect command"**. -4. Copy the **single** command in the dialog → paste it into **another terminal on the same host**. The command looks like: - ```bash - curl -fsSL http://127.0.0.1:18080/api/v1/parsar-daemon/install.sh \ - | PARSAR_DAEMON_CONNECT_URL=http://127.0.0.1:18080 \ - PARSAR_DAEMON_CONNECT_TOKEN= \ - PARSAR_DAEMON_CONNECT_DEVICE_NAME= bash - ``` - - The server URL is filled in automatically from `PARSAR_PUBLIC_URL`, **do not edit it by hand**. - - The script: downloads the platform-appropriate daemon from your server → `chmod` → runs `connect` in the background; the token is passed via env vars rather than argv, so it never appears in `ps` output or access logs. You **never touch** the binary, its path, or the token. -5. Within a few seconds the device flips from `pending_pairing` → **"online"**. **E2E passed.** - If you want to confirm the device can actually do work: create an Agent and run through one issue. - ---- - -## 5 · Stop / clean up +## Manage The Stack ```bash -docker compose -f docker-compose.local.yml down # stop, keep the data volume -docker compose -f docker-compose.local.yml down -v # also drop the Postgres data +docker compose -f ~/.parsar/compose.yml --env-file ~/.parsar/.env ps +docker compose -f ~/.parsar/compose.yml --env-file ~/.parsar/.env logs -f parsar-server +docker compose -f ~/.parsar/compose.yml --env-file ~/.parsar/.env down ``` ---- - -## Troubleshooting +To remove all local data, stop the stack first and then delete `~/.parsar/`. -| Symptom | Cause | Fix | -|---|---|---| -| Device pairing downloads daemon and hits **404** | The GHCR image you pulled does not contain the embedded daemon (not published yet) | Use the local build from step 2: `export PARSAR_SERVER_IMAGE=parsar:local` then `up -d --force-recreate` | -| Port reports `address already in use` | 18080 / 15432 is taken | Change `PARSAR_LOCAL_PORT` / `PARSAR_PG_PORT` and `up -d` again | -| `server` crash-loops with `agent_daemon owner URL not resolvable` | Missing `PARSAR_AGENT_DAEMON_OWNER_URL` (the local compose already defaults it to `http://parsar-server:8080`) | If you edited / removed that env, restore it | -| `server` stays unhealthy but is actually reachable | The image's built-in HEALTHCHECK uses HEAD, but `/healthz` only accepts GET (the local compose overrides the probe to GET) | If you edited compose, confirm the healthcheck uses GET | -| Device is online but no `kind` is available when creating an Agent | No Agent CLI is installed / logged in on this host | Log into `claude` / `opencode` / `codex` on this machine — the daemon picks it up automatically | -| macOS `exec format error` | Built on x86 but running on Apple Silicon | Re-run `make docker-build` locally to build for the native architecture | +## Advanced Configuration -Logs: `docker logs -f parsar-local-server` - ---- - -## TL;DR +Edit `~/.parsar/.env` and rerun: ```bash -git clone parsar && cd parsar && git checkout feature/deliverables-design -export PARSAR_LOCAL_PORT=18080 PARSAR_PG_PORT=15432 -# Current phase (GHCR has no image with the embedded daemon yet) → build locally; -# once published these two lines can go and the default image works. -make docker-build PARSAR_IMAGE=parsar PARSAR_IMAGE_TAG=local -export PARSAR_SERVER_IMAGE=parsar:local -docker compose -f docker-compose.local.yml up -d -# Browser http://127.0.0.1:18080 → log in → Pair new device → copy command → run in another terminal → device online +curl -fsSL https://raw.githubusercontent.com/MiniMax-AI-Dev/parsar/main/install.sh | bash ``` + +The installer preserves generated secrets and refreshes non-secret settings +such as ports, image names, and paths. diff --git a/README.md b/README.md index dd7f851..5466b67 100644 --- a/README.md +++ b/README.md @@ -35,18 +35,27 @@ Supported agent runtimes: ## Quick Start -Requires only `git` and `docker`. +Requires Docker with Docker Compose v2. ```bash -git clone https://github.com/MiniMax-AI-Dev/parsar.git -cd parsar -docker build -t parsar:local . -PARSAR_SERVER_IMAGE=parsar:local docker compose -f docker-compose.local.yml up +curl -fsSL https://raw.githubusercontent.com/MiniMax-AI-Dev/parsar/main/install.sh | bash ``` -Open . Mock auth signs you in as `admin@example.com` — no secrets, no `.env`, no config. +Open and create the first owner account in the web +setup flow. The first registered user is the administrator. -> **Platform.** Verified on Linux/amd64. The Agent sandbox image is currently amd64-only, so Apple Silicon (arm64) is not yet supported out of the box. +The installer writes generated config, secrets, database files, and runtime +state under `~/.parsar/`. + +For local development from a checkout: + +```bash +make docker-build +./install.sh --image parsar:dev +``` + +> **Platform.** Docker-managed agent sandboxes require Linux. Non-Linux hosts +> can start the web control plane with `--no-sandbox`. ## Contributing diff --git a/apps/web/src/i18n/locales/en-US/admin.json b/apps/web/src/i18n/locales/en-US/admin.json index 09750d7..1960710 100644 --- a/apps/web/src/i18n/locales/en-US/admin.json +++ b/apps/web/src/i18n/locales/en-US/admin.json @@ -1803,6 +1803,22 @@ "runtimeRoot": "Local runtime root", "runtimeRootHint": "Config, logs, state, and cache live here instead of the repository root." }, + "authentication": { + "title": "Authentication", + "description": "Read-only login provider status from deployment configuration. Secrets stay in env or your secret manager.", + "loading": "Loading auth provider status...", + "error": "Could not load auth provider status.", + "callbackUrl": "Callback URL", + "requiredEnv": "Required env", + "missingEnv": "Missing env", + "noneMissing": "No required env is missing.", + "docsHint": "Deployment guide:", + "status": { + "enabled": "Enabled", + "configured": "Configured", + "missing": "Not configured" + } + }, "gatewayDefaults": { "title": "Gateway defaults", "description": "Defaults applied when a new gateway is wired up. Existing gateways aren't affected.", diff --git a/apps/web/src/i18n/locales/en-US/common.json b/apps/web/src/i18n/locales/en-US/common.json index 7eea845..0e76ccb 100644 --- a/apps/web/src/i18n/locales/en-US/common.json +++ b/apps/web/src/i18n/locales/en-US/common.json @@ -111,9 +111,6 @@ "login": { "title": "Parsar", "subtitle": "Team agent control plane", - "feishuButton": "Sign in with Feishu", - "firstLoginNote": "First-time sign-in will create your account automatically", - "terms": "Signing in means you agree to the Terms", "loading": "Loading sign-in status...", "emailLabel": "Email", "emailPlaceholder": "you@company.com", @@ -123,7 +120,8 @@ "submitting": "Signing in...", "invalidCredentials": "Invalid email or password", "genericError": "Something went wrong. Please try again.", - "orDivider": "or", + "ssoDivider": "SSO", + "ssoButton": "Sign in with {{provider}}", "noAccountHint": "First time here? Ask your admin for an invite." }, "setup": { diff --git a/apps/web/src/i18n/locales/zh-CN/admin.json b/apps/web/src/i18n/locales/zh-CN/admin.json index 08a9fb7..b5e154c 100644 --- a/apps/web/src/i18n/locales/zh-CN/admin.json +++ b/apps/web/src/i18n/locales/zh-CN/admin.json @@ -1803,6 +1803,22 @@ "runtimeRoot": "本地 Runtime Root", "runtimeRootHint": "运行配置、日志、状态和缓存统一放在这里,不写入仓库根目录。" }, + "authentication": { + "title": "认证", + "description": "只读展示部署配置里的登录 Provider 状态。Secret 仍由 env 或 secret manager 管理。", + "loading": "正在加载认证 Provider 状态...", + "error": "无法加载认证 Provider 状态。", + "callbackUrl": "Callback URL", + "requiredEnv": "必需 env", + "missingEnv": "缺失 env", + "noneMissing": "没有缺失的必需 env。", + "docsHint": "部署文档:", + "status": { + "enabled": "已启用", + "configured": "已配置", + "missing": "未配置" + } + }, "gatewayDefaults": { "title": "网关默认值", "description": "新接入网关时自动套用的默认行为。已配置的网关不受影响。", diff --git a/apps/web/src/i18n/locales/zh-CN/common.json b/apps/web/src/i18n/locales/zh-CN/common.json index 33b582f..88c8727 100644 --- a/apps/web/src/i18n/locales/zh-CN/common.json +++ b/apps/web/src/i18n/locales/zh-CN/common.json @@ -111,9 +111,6 @@ "login": { "title": "Parsar", "subtitle": "团队 Agent 协作控制台", - "feishuButton": "使用飞书登录", - "firstLoginNote": "首次登录会自动创建账号", - "terms": "登录即表示同意《使用条款》", "loading": "登录态加载中...", "emailLabel": "邮箱", "emailPlaceholder": "you@company.com", @@ -123,7 +120,8 @@ "submitting": "登录中...", "invalidCredentials": "邮箱或密码错误", "genericError": "发生错误,请稍后重试。", - "orDivider": "或", + "ssoDivider": "SSO", + "ssoButton": "使用 {{provider}} 登录", "noAccountHint": "首次访问?请联系管理员发送邀请。" }, "setup": { diff --git a/apps/web/src/lib/api-auth.ts b/apps/web/src/lib/api-auth.ts index fc9919b..63a4224 100644 --- a/apps/web/src/lib/api-auth.ts +++ b/apps/web/src/lib/api-auth.ts @@ -1,6 +1,6 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { apiRequest } from "./api-client" +import { apiRequest, noUnreachableRetry } from "./api-client" export interface MeResponse { user_id: string @@ -21,6 +21,62 @@ export function feishuLoginUrl(): string { return "/api/v1/auth/feishu/start" } +export interface PublicAuthProvider { + id: string + type: "password" | "oauth" | "oidc" | "saml" | string + label: string + enabled: boolean + login_url?: string +} + +export interface AuthProvidersResponse { + providers: PublicAuthProvider[] +} + +export interface WorkspaceAuthProvider extends PublicAuthProvider { + configured: boolean + callback_url?: string + required_env?: string[] + missing_env?: string[] + docs_url?: string +} + +export interface WorkspaceAuthProvidersResponse { + workspace_id: string + providers: WorkspaceAuthProvider[] +} + +export function getAuthProviders(): Promise { + return apiRequest("/api/v1/auth/providers") +} + +export function getWorkspaceAuthProviders( + workspaceID: string, +): Promise { + return apiRequest( + `/api/v1/workspaces/${workspaceID}/auth/providers`, + ) +} + +export function useAuthProviders() { + return useQuery({ + queryKey: ["auth", "providers"], + queryFn: getAuthProviders, + retry: noUnreachableRetry, + staleTime: 30_000, + }) +} + +export function useWorkspaceAuthProviders(workspaceID: string | null | undefined) { + return useQuery({ + queryKey: ["admin", "authProviders", workspaceID ?? "_none"], + queryFn: () => getWorkspaceAuthProviders(workspaceID ?? ""), + enabled: Boolean(workspaceID), + retry: noUnreachableRetry, + staleTime: 30_000, + }) +} + /* --- Email/password login --------------------------------------------- * * Mirrors server/internal/auth/password/handler.go. The server issues diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx index a445ef2..ad71b9e 100644 --- a/apps/web/src/pages/LoginPage.tsx +++ b/apps/web/src/pages/LoginPage.tsx @@ -2,7 +2,7 @@ import { useState, type FormEvent } from "react" import { useTranslation } from "react-i18next" import { ApiError } from "../lib/api-client" -import { feishuLoginUrl, useLoginWithPassword } from "../lib/api-auth" +import { useAuthProviders, useLoginWithPassword } from "../lib/api-auth" import { useBootstrapStatus } from "../lib/api-bootstrap" import { SetupPage } from "./SetupPage" @@ -11,12 +11,7 @@ import { SetupPage } from "./SetupPage" * * Behavior branches on `GET /api/v1/bootstrap/status`: * status.needed=true -> render (first-owner registration) - * status.needed=false -> render the email/password form + Feishu button - * - * We deliberately do NOT hide the Feishu button in the setup path: - * once the first owner is created they still need Feishu to onboard - * additional team members in Feishu-first tenants. The two paths are - * meant to coexist forever. + * status.needed=false -> render the email/password form */ export function LoginPage() { const { t } = useTranslation("common") @@ -38,12 +33,17 @@ export function LoginPage() { function SignInView() { const { t } = useTranslation("common") const loginM = useLoginWithPassword() + const providersQ = useAuthProviders() const [email, setEmail] = useState("") const [password, setPassword] = useState("") const submitting = loginM.isPending const invalid = email.trim() === "" || password === "" + const ssoProviders = + providersQ.data?.providers.filter( + (p) => p.enabled && p.id !== "password" && Boolean(p.login_url), + ) ?? [] const errorMsg = (() => { const err = loginM.error @@ -124,18 +124,27 @@ function SignInView() { -
- - {t("login.orDivider")} - -
+ {ssoProviders.length > 0 && ( + <> +
+ + {t("login.ssoDivider")} + +
- - {t("login.feishuButton")} - +
+ {ssoProviders.map((provider) => ( + + {t("login.ssoButton", { provider: provider.label })} + + ))} +
+ + )}

{t("login.noAccountHint")} diff --git a/apps/web/src/pages/admin/SettingsPage.tsx b/apps/web/src/pages/admin/SettingsPage.tsx index 903c041..ca36b6f 100644 --- a/apps/web/src/pages/admin/SettingsPage.tsx +++ b/apps/web/src/pages/admin/SettingsPage.tsx @@ -7,6 +7,7 @@ import { SettingsTabs } from "../../components/layout/SettingsTabs" import { Input } from "../../components/ui/input" import { SUPPORTED_LANGUAGES, type SupportedLanguage } from "../../i18n" import { cn } from "../../lib/utils" +import { useWorkspaceAuthProviders, type WorkspaceAuthProvider } from "../../lib/api-auth" import { useMyWorkspaces } from "../../lib/api-workspaces" import { useWorkspaceId } from "../../lib/workspace" @@ -15,6 +16,7 @@ export function SettingsPage() { const { t: tc } = useTranslation("common") const wsId = useWorkspaceId() const workspacesQ = useMyWorkspaces() + const authProvidersQ = useWorkspaceAuthProviders(wsId) const workspace = workspacesQ.data?.workspaces.find((w) => w.id === wsId) const currentLang = (i18n.resolvedLanguage ?? "en-US") as SupportedLanguage @@ -72,6 +74,23 @@ export function SettingsPage() { +

+ {authProvidersQ.isLoading ? ( +

{t("settings.authentication.loading")}

+ ) : authProvidersQ.isError ? ( +

{t("settings.authentication.error")}

+ ) : ( +
+ {(authProvidersQ.data?.providers ?? []).map((provider) => ( + + ))} +
+ )} +
+
+
+
+
{provider.label}
+
{provider.type}
+
+ + {statusLabel} + +
+ + {provider.callback_url && ( +
+
+ {t("settings.authentication.callbackUrl")} +
+ + {provider.callback_url} + +
+ )} + + {required.length > 0 && ( +
+ + +
+ )} + + {provider.docs_url && ( +
+ {t("settings.authentication.docsHint")}{" "} + {provider.docs_url} +
+ )} + + ) +} + +function EnvList({ + title, + items, + emptyLabel, +}: { + title: string + items: string[] + emptyLabel?: string +}) { + return ( +
+
{title}
+ {items.length > 0 ? ( +
+ {items.map((item) => ( + + {item} + + ))} +
+ ) : ( +

{emptyLabel ?? ""}

+ )} +
+ ) +} + /* ------------------------------------------------------------------ */ /* Form helpers */ /* ------------------------------------------------------------------ */ diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index 849dea4..fc13e94 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -24,7 +24,7 @@ # image baked by .github/workflows/parsar-server-release.yml. For production, # pin a real tag (commit sha / semver) instead of `latest`; mirror to your # own registry (e.g. Aliyun ACR) and point this there for in-region pulls. -PARSAR_SERVER_IMAGE=ghcr.io/MiniMax-ai-dev/parsar-server:latest +PARSAR_SERVER_IMAGE=ghcr.io/minimax-ai-dev/parsar-server:latest # ----------------------------------------------------------------------------- # Host port mapping @@ -48,9 +48,9 @@ PARSAR_PG_DB=parsar # ----------------------------------------------------------------------------- # Public-facing URL # ----------------------------------------------------------------------------- -# Absolute URL the browser hits. Used to construct OAuth callback -# URLs (Feishu / GitHub / Slack / future providers). MUST start with https:// -# in production, MUST match what your reverse proxy serves on. +# Absolute URL the browser hits. Used to construct OAuth callback URLs +# for optional providers such as Feishu and GitHub. MUST start with +# https:// in production and MUST match what your reverse proxy serves on. PARSAR_PUBLIC_URL=https://parsar. # ----------------------------------------------------------------------------- @@ -64,26 +64,24 @@ PARSAR_PUBLIC_URL=https://parsar. PARSAR_MASTER_KEY= # ----------------------------------------------------------------------------- -# Feishu OIDC + event subscription +# Optional Feishu SSO + event subscription # ----------------------------------------------------------------------------- -# Quickstart default: PARSAR_FEISHU_MOCK=true so a fresh clone can run -# `docker compose up` without registering a Feishu OAuth app first. -# This switches the server to the dev profile and skips the four Feishu -# checks (APP_ID, APP_SECRET, REDIRECT_URI, VERIFICATION_TOKEN). -# -# Production: set PARSAR_FEISHU_MOCK=false (or remove this line) and -# fill in the four required values. Server startup refuses partial -# config so a typo cannot silently disable login. +# Leave these empty for the default first-owner setup and email/password +# login. Fill in the values only when enabling Feishu SSO or Feishu IM +# event delivery. # # Where to get the production values: # docs/deploy/feishu-prod.md §1 (Feishu open platform setup) -PARSAR_FEISHU_MOCK=true +PARSAR_FEISHU_MOCK= PARSAR_FEISHU_APP_ID= PARSAR_FEISHU_APP_SECRET= PARSAR_FEISHU_REDIRECT_URI= PARSAR_FEISHU_VERIFICATION_TOKEN= # Only set when Feishu event encryption is on: PARSAR_FEISHU_ENCRYPT_KEY= +# Enables Feishu Bot app provisioning endpoints. Leave blank unless you expose +# Feishu connector setup in this deployment. +PARSAR_FEISHU_APP_REGISTRATION= # Login redirect post-callback (defaults to "/"): PARSAR_LOGIN_REDIRECT_URL= diff --git a/deploy/compose/README.md b/deploy/compose/README.md index 540c558..042cafc 100644 --- a/deploy/compose/README.md +++ b/deploy/compose/README.md @@ -56,8 +56,8 @@ which does not contain them): defaults to `/usr/local/bin/parsar-server`). - `parsar-migrate` — the goose-driven migration runner used by the runbook's `docker compose ... exec parsar-server parsar-migrate` step. -- `parsar-bootstrap` — the first-owner provisioning CLI (used when the - HTTP bootstrap endpoint is disabled). +- `parsar-bootstrap` — optional headless first-owner provisioning CLI + for automated deployments that do not use the web setup flow. The Dockerfile that builds this image lives at the repo root (`Dockerfile`); `make docker-build` produces an image satisfying this diff --git a/deploy/compose/compose.selfhost.yml b/deploy/compose/compose.selfhost.yml index 9a9ff57..28577c9 100644 --- a/deploy/compose/compose.selfhost.yml +++ b/deploy/compose/compose.selfhost.yml @@ -28,7 +28,7 @@ # 4. Adjust the bind-mount paths below to match where you put # the config file and where you want runtime state to live. # 5. docker compose -f deploy/compose/compose.selfhost.yml --env-file .env up -d -# 6. Follow deploy-runbook.md §3 to bootstrap the first owner. +# 6. Open the web UI and create the first owner account. # # Hard rules baked into this file: # - Postgres data lives on a named volume, never inside the repo CWD. @@ -81,7 +81,7 @@ services: # Defaults to the GHCR prebuilt image (same one docker-compose.local.yml # uses — profile not fork). Override PARSAR_SERVER_IMAGE in .env to pull # from your own registry (e.g. an Aliyun ACR mirror) or a pinned tag. - image: ${PARSAR_SERVER_IMAGE:-ghcr.io/MiniMax-ai-dev/parsar-server:latest} + image: ${PARSAR_SERVER_IMAGE:-ghcr.io/minimax-ai-dev/parsar-server:latest} container_name: parsar-server restart: unless-stopped depends_on: @@ -115,11 +115,10 @@ services: PARSAR_DEV_AUTH: "false" PARSAR_COOKIE_SECURE: ${PARSAR_COOKIE_SECURE:-true} - # ---------- Feishu OIDC + event subscription ---------- - # PARSAR_FEISHU_MOCK=true is the quickstart default in - # .env.example so a fresh clone can `docker compose up` without - # registering a Feishu OAuth app first. Set to false (or unset) - # before production and supply all four below. + # ---------- Optional Feishu SSO + event subscription ---------- + # Leave empty for the default first-owner setup and email/password + # login. Fill these in only when enabling Feishu SSO or Feishu IM + # event delivery. PARSAR_DAEMON_REPO: ${PARSAR_DAEMON_REPO} PARSAR_FEISHU_MOCK: ${PARSAR_FEISHU_MOCK} PARSAR_FEISHU_APP_ID: ${PARSAR_FEISHU_APP_ID} @@ -127,6 +126,7 @@ services: PARSAR_FEISHU_REDIRECT_URI: ${PARSAR_FEISHU_REDIRECT_URI} PARSAR_FEISHU_VERIFICATION_TOKEN: ${PARSAR_FEISHU_VERIFICATION_TOKEN} PARSAR_FEISHU_ENCRYPT_KEY: ${PARSAR_FEISHU_ENCRYPT_KEY} + PARSAR_FEISHU_APP_REGISTRATION: ${PARSAR_FEISHU_APP_REGISTRATION} PARSAR_LOGIN_REDIRECT_URL: ${PARSAR_LOGIN_REDIRECT_URL} # Host overrides — leave blank unless you reverse-proxy Feishu # through a private endpoint. diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 417a59f..6e2583c 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -4,10 +4,9 @@ # # docker compose -f docker-compose.local.yml up # -# Brings up Postgres + a one-shot init (migrate + first-owner bootstrap) + -# parsar-server with mock auth. Open http://127.0.0.1:18080, click -# "Pair new device", copy the single command, run it on THIS host, and your -# local Claude Code / OpenCode / Codex login is paired in. +# Brings up Postgres + a one-shot migration init + parsar-server. Open +# http://127.0.0.1:18080 and create the first owner account in the web setup +# flow. # # Profile not fork: this is the SAME image as the self-host deployment # (deploy/compose/compose.selfhost.yml). Differences live only in this file @@ -24,6 +23,7 @@ # PARSAR_SERVER_IMAGE server image ref (default: GHCR latest) # PARSAR_LOCAL_PORT host port for the web UI (default: 18080) # PARSAR_PG_PORT host port for Postgres (default: 15432) +# PARSAR_BIND_ADDR host bind address (default: 127.0.0.1) # Explicit project name so the auto-created network is always parsar_default # regardless of the clone directory name. The sandbox provider hard-codes this @@ -55,80 +55,26 @@ services: retries: 20 start_period: 10s - # One-shot: run migrations, then (optionally) provision the first owner. - # Set PARSAR_OWNER_EMAIL to pre-seed an owner via CLI bootstrap; leave - # unset to use the web registration form (POST /api/v1/bootstrap). - # Tolerates exit code 2 (store.ErrBootstrapClosed) so re-running `up` is - # idempotent. Binaries live on $PATH at /usr/local/bin (WORKDIR is - # /var/lib/parsar), so they are invoked by bare name, NOT ./parsar-migrate. + # One-shot: run migrations before the server starts. First owner creation + # happens in the web setup flow (POST /api/v1/bootstrap). parsar-init: - image: ${PARSAR_SERVER_IMAGE:-ghcr.io/MiniMax-ai-dev/parsar-server:latest} + image: ${PARSAR_SERVER_IMAGE:-ghcr.io/minimax-ai-dev/parsar-server:latest} container_name: parsar-local-init depends_on: postgres: condition: service_healthy environment: DATABASE_URL: postgres://parsar:parsar@postgres:5432/parsar?sslmode=disable - PARSAR_OWNER_EMAIL: "${PARSAR_OWNER_EMAIL:-}" - # Passed through so parsar-init can validate it before migrations run. - # Empty / dev-default triggers the auto-generate + guidance path below. - PARSAR_MASTER_KEY: "${PARSAR_MASTER_KEY:-}" entrypoint: ["/bin/sh", "-c"] command: - | set -e - # ---- Master key gate ---- - # PARSAR_MASTER_KEY encrypts every workspace-scoped secret in the - # database (Feishu App Secret, Slack/Discord bot tokens, ...). A - # public dev-default key or an empty value on a 0.0.0.0-bound LAN - # deploy would leak that vault to anyone who can read the DB. Fail - # loud on the well-known dev default; auto-generate + print - # guidance for empty so first-time `up` still works. - DEV_DEFAULT_KEY="parsar-dev-master-key-2026" - if [ "$${PARSAR_MASTER_KEY}" = "$${DEV_DEFAULT_KEY}" ]; then - echo "═══════════════════════════════════════════════════════════════" - echo " ERROR: PARSAR_MASTER_KEY is the public dev default." - echo "" - echo " This key encrypts Feishu / Slack / Discord bot credentials" - echo " in the database. Using the repo-visible default on a LAN" - echo " deploy leaks the entire secret vault." - echo "" - echo " Fix: remove PARSAR_MASTER_KEY from .env, then re-run" - echo " docker compose up — a fresh key is auto-generated and" - echo " printed below." - echo "═══════════════════════════════════════════════════════════════" - exit 1 - fi - if [ -z "$${PARSAR_MASTER_KEY}" ]; then - GENERATED_KEY=$$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n') - echo "═══════════════════════════════════════════════════════════════" - echo " PARSAR_MASTER_KEY was unset — auto-generated a fresh key." - echo "" - echo " ⚠️ COPY THIS INTO .env NOW, otherwise the next restart will" - echo " generate a different key and every bot credential you" - echo " store today will become undecryptable." - echo "" - echo " PARSAR_MASTER_KEY=$${GENERATED_KEY}" - echo "" - echo " (Sleeping 5s so this stays on screen. Ctrl+C to abort.)" - echo "═══════════════════════════════════════════════════════════════" - sleep 5 - export PARSAR_MASTER_KEY="$${GENERATED_KEY}" - fi parsar-migrate - if [ -n "$${PARSAR_OWNER_EMAIL}" ]; then - parsar-bootstrap \ - --email="$${PARSAR_OWNER_EMAIL}" \ - --workspace="Local Workspace" \ - --name="Local Admin" \ - || [ "$$?" -eq 2 ] - else - echo "[parsar-init] PARSAR_OWNER_EMAIL unset — skipping bootstrap; use the web registration form" - fi + echo "[parsar-init] migrations complete — create the first owner in the web setup flow" restart: "no" parsar-server: - image: ${PARSAR_SERVER_IMAGE:-ghcr.io/MiniMax-ai-dev/parsar-server:latest} + image: ${PARSAR_SERVER_IMAGE:-ghcr.io/minimax-ai-dev/parsar-server:latest} container_name: parsar-local-server restart: unless-stopped depends_on: @@ -137,7 +83,7 @@ services: parsar-init: condition: service_completed_successfully ports: - - "${PARSAR_BIND_ADDR:-0.0.0.0}:${PARSAR_LOCAL_PORT:-18080}:8080" + - "${PARSAR_BIND_ADDR:-127.0.0.1}:${PARSAR_LOCAL_PORT:-18080}:8080" environment: DATABASE_URL: postgres://parsar:parsar@postgres:5432/parsar?sslmode=disable # Network proxy — only needed if your host requires a proxy for internet @@ -145,25 +91,9 @@ services: http_proxy: "${HTTP_PROXY:-}" https_proxy: "${HTTPS_PROXY:-}" no_proxy: "localhost,127.0.0.1,postgres,parsar-server" - # Auth mode. Default (no .env) = mock auth -> ProfileDev: full cookie - # login as admin@example.com, no IdP, zero secrets. To test REAL Feishu - # OIDC against this same 127.0.0.1 stack, drop a gitignored .env next to - # this file with the real values (compose auto-loads it): - # - # PARSAR_FEISHU_MOCK=false - # PARSAR_FEISHU_APP_ID=cli_xxx - # PARSAR_FEISHU_APP_SECRET=xxx - # PARSAR_FEISHU_VERIFICATION_TOKEN=xxx # any non-empty value - # - # - # No PARSAR_MASTER_KEY / PARSAR_COOKIE_SECURE needed: a loopback - # PARSAR_PUBLIC_URL (127.0.0.1) keeps Profile()==ProfileDev even with - # real Feishu (see config.isLoopback), so the dev master key is - # auto-seeded and the secure-cookie check is skipped. Then register - # http://127.0.0.1:18080/api/v1/auth/feishu/callback as a redirect URL - # in the Feishu console. (PARSAR_DEV_AUTH stays unset: we want real - # cookie login, not the header bypass.) - PARSAR_FEISHU_MOCK: "${PARSAR_FEISHU_MOCK:-true}" + # Optional Feishu SSO. Leave unset for the default email/password setup. + # Set PARSAR_FEISHU_MOCK=true only for explicit local SSO testing. + PARSAR_FEISHU_MOCK: "${PARSAR_FEISHU_MOCK:-}" PARSAR_FEISHU_REDIRECT_URI: "${PARSAR_FEISHU_REDIRECT_URI:-}" PARSAR_FEISHU_APP_ID: "${PARSAR_FEISHU_APP_ID:-}" PARSAR_FEISHU_APP_SECRET: "${PARSAR_FEISHU_APP_SECRET:-}" @@ -189,24 +119,14 @@ services: # Same service-name default the self-host compose uses (profile not # fork) — `parsar-server` is this service's network alias. PARSAR_AGENT_DAEMON_OWNER_URL: "http://parsar-server:8080" - # Feishu Bot IM (local websocket path, not webhook — 127.0.0.1 is not - # reachable by Feishu's HTTPS event delivery). WEBSOCKET turns inbound - # @Bot messages into gateway runs; OUTBOUND drives the inflight reply - # cards back. Both read PARSAR_MASTER_KEY via os.Getenv (the dev - # auto-seed does NOT satisfy them), so it must be set explicitly here. - # Pinned to the SAME dev default the web admin UI hardcodes - # (apps/web/src/lib/api-models.ts DEV_MASTER_KEY) so the secret vault - # stays in sync. Bot App ID/Secret are NOT here — they go through the - # Web UI bind card into the encrypted vault. - # PARSAR_MASTER_KEY must be set explicitly. Empty falls through here - # so parsar-init can auto-generate + print the key on first `up`; - # parsar-server then refuses to start via config.Validate in - # production mode (see server/internal/config/validate.go). No dev - # fallback: leaking the public dev key on a 0.0.0.0-bound LAN - # deploy would expose the entire secret vault. + # Optional connector credentials and workers. On the loopback default, + # the server seeds a stable dev-only PARSAR_MASTER_KEY so one-command + # local startup needs no secrets. Set a real 64-hex key when exposing + # this compose stack beyond loopback. PARSAR_MASTER_KEY: "${PARSAR_MASTER_KEY:-}" - PARSAR_FEISHU_WEBSOCKET: "true" - PARSAR_FEISHU_OUTBOUND: "true" + PARSAR_FEISHU_APP_REGISTRATION: "${PARSAR_FEISHU_APP_REGISTRATION:-}" + PARSAR_FEISHU_WEBSOCKET: "${PARSAR_FEISHU_WEBSOCKET:-}" + PARSAR_FEISHU_OUTBOUND: "${PARSAR_FEISHU_OUTBOUND:-}" # Workspace-dimension IM connector reconcilers (workspace_im_connectors # table → one Socket Mode / Gateway runner per workspace|app_id, hot- # reloading on token rotation). Opt-in gates read in main.go @@ -248,6 +168,7 @@ services: AGENT_DAEMON_SANDBOX_BACKEND: "docker" AGENT_DAEMON_SANDBOX_DOCKER_IMAGE: "${AGENT_DAEMON_SANDBOX_DOCKER_IMAGE:-parsar-sandbox:local}" AGENT_DAEMON_SANDBOX_DOCKER_NETWORK: "parsar_default" + AGENT_DAEMON_SANDBOX_SERVER_URL: "http://parsar-server:8080" group_add: - "${DOCKER_GID:-999}" volumes: diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index c59f40d..790e515 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -183,6 +183,33 @@ definitions: workspaceID: type: string type: object + dev.AuthProvider: + properties: + callback_url: + type: string + configured: + type: boolean + docs_url: + type: string + enabled: + type: boolean + id: + type: string + label: + type: string + login_url: + type: string + missing_env: + items: + type: string + type: array + required_env: + items: + type: string + type: array + type: + type: string + type: object dev.addWorkspaceMemberRequest: properties: email: @@ -204,6 +231,13 @@ definitions: send a value so the server doesn't have to guess. type: string type: object + dev.authProvidersResponse: + properties: + providers: + items: + $ref: '#/definitions/dev.publicAuthProvider' + type: array + type: object dev.builtinCapabilityBody: properties: enabled: @@ -575,6 +609,19 @@ definitions: upload_source: type: string type: object + dev.publicAuthProvider: + properties: + enabled: + type: boolean + id: + type: string + label: + type: string + login_url: + type: string + type: + type: string + type: object dev.requeueAgentRunBody: properties: reason: @@ -899,6 +946,15 @@ definitions: updated_at: type: string type: object + dev.workspaceAuthProvidersResponse: + properties: + providers: + items: + $ref: '#/definitions/dev.AuthProvider' + type: array + workspace_id: + type: string + type: object password.errorResponse: properties: code: @@ -1265,9 +1321,8 @@ definitions: info: contact: {} description: |- - Parsar harness API. Feishu production auth/event deployments require - PARSAR_FEISHU_APP_ID, PARSAR_FEISHU_APP_SECRET, PARSAR_FEISHU_REDIRECT_URI - and PARSAR_FEISHU_VERIFICATION_TOKEN when PARSAR_FEISHU_MOCK is not true. + Parsar harness API. Optional Feishu SSO and event delivery + are enabled only when the corresponding PARSAR_FEISHU_* values are configured. license: name: Apache 2.0 url: https://www.apache.org/licenses/LICENSE-2.0.html @@ -2604,6 +2659,21 @@ paths: summary: Log out the current session tags: - auth + /api/v1/auth/providers: + get: + description: Returns the login providers the public login page may render. Sensitive + diagnostics are omitted. + operationId: listAuthProviders + produces: + - application/json + responses: + "200": + description: Auth providers + schema: + $ref: '#/definitions/dev.authProvidersResponse' + summary: List auth providers + tags: + - auth /api/v1/bootstrap: post: consumes: @@ -4981,6 +5051,39 @@ paths: summary: List workspace audit records tags: - workspaces + /api/v1/workspaces/{workspaceID}/auth/providers: + get: + description: Returns read-only auth provider status for workspace owners/admins. + Secret values are never returned. + operationId: listWorkspaceAuthProviders + parameters: + - description: Workspace UUID + in: path + name: workspaceID + required: true + type: string + produces: + - application/json + responses: + "200": + description: Auth provider diagnostics + schema: + $ref: '#/definitions/dev.workspaceAuthProvidersResponse' + "400": + description: workspace_id must be a valid uuid + schema: + additionalProperties: + type: string + type: object + "403": + description: Caller is not workspace owner/admin + schema: + additionalProperties: + type: string + type: object + summary: List workspace auth provider diagnostics + tags: + - auth /api/v1/workspaces/{workspaceID}/capabilities: get: description: Returns the workspace's own capabilities plus its marketplace installs diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..58a034f --- /dev/null +++ b/install.sh @@ -0,0 +1,372 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEFAULT_SERVER_IMAGE="ghcr.io/minimax-ai-dev/parsar-server:latest" +DEFAULT_SANDBOX_IMAGE="ghcr.io/minimax-ai-dev/parsar-sandbox:latest" + +usage() { + cat <<'EOF' +Parsar one-command installer. + +Usage: + ./install.sh [options] + curl -fsSL https://raw.githubusercontent.com/MiniMax-AI-Dev/parsar/main/install.sh | bash + +Options: + --home PATH Install state directory. Must be absolute or ~/... + Default: ~/.parsar + --image IMAGE parsar-server image. + Default: ghcr.io/minimax-ai-dev/parsar-server:latest + --sandbox-image IMAGE Docker sandbox image. + Default: ghcr.io/minimax-ai-dev/parsar-sandbox:latest + --port PORT Web UI host port. Default: 18080 + --pg-port PORT Postgres host port. Default: 15432 + --bind ADDR Web UI bind address. Default: 127.0.0.1 + --public-url URL Browser-facing URL. Default: http://127.0.0.1: + --project-name NAME Docker Compose project name. Default: parsar + --no-sandbox Start without Docker-managed agent sandboxes. + --dry-run Generate files and validate compose config only. + --help Show this help. + +Environment variables with the same names are also honored: + PARSAR_HOME, PARSAR_SERVER_IMAGE, PARSAR_SANDBOX_IMAGE, + PARSAR_LOCAL_PORT, PARSAR_PG_PORT, PARSAR_BIND_ADDR, + PARSAR_PUBLIC_URL, PARSAR_PROJECT_NAME, PARSAR_NO_SANDBOX. +EOF +} + +log() { + printf '[parsar-install] %s\n' "$*" +} + +die() { + printf '[parsar-install] ERROR: %s\n' "$*" >&2 + exit 1 +} + +expand_home_path() { + case "$1" in + "~") printf '%s\n' "$HOME" ;; + "~/"*) printf '%s/%s\n' "$HOME" "${1#~/}" ;; + /*) printf '%s\n' "$1" ;; + *) die "path must be absolute or start with ~/; got: $1" ;; + esac +} + +random_hex() { + bytes="$1" + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex "$bytes" + return + fi + od -An -N"$bytes" -tx1 /dev/urandom | tr -d ' \n' +} + +ensure_env() { + key="$1" + value="$2" + file="$3" + if grep -q "^${key}=" "$file" 2>/dev/null; then + return + fi + printf '%s=%s\n' "$key" "$value" >>"$file" +} + +set_env() { + key="$1" + value="$2" + file="$3" + tmp="${file}.tmp.$$" + grep -v "^${key}=" "$file" 2>/dev/null >"$tmp" || true + printf '%s=%s\n' "$key" "$value" >>"$tmp" + mv "$tmp" "$file" +} + +detect_docker() { + if docker compose version >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + DOCKER=(docker) + return + fi + if command -v sudo >/dev/null 2>&1 && + sudo -n docker compose version >/dev/null 2>&1 && + sudo -n docker info >/dev/null 2>&1; then + DOCKER=(sudo -n docker) + return + fi + die "Docker Compose v2 is required and the current user cannot reach the Docker daemon" +} + +docker_run() { + "${DOCKER[@]}" "$@" +} + +compose_run() { + docker_run compose -f "$compose_file" --env-file "$env_file" "$@" +} + +write_compose() { + compose_file="$1" + enable_sandbox="$2" + + cat >"$compose_file" <<'EOF' +name: ${PARSAR_PROJECT_NAME} + +networks: + default: + name: ${PARSAR_PROJECT_NAME}_default + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: parsar + POSTGRES_PASSWORD: ${PARSAR_PG_PASSWORD} + POSTGRES_DB: parsar + ports: + - "127.0.0.1:${PARSAR_PG_PORT}:5432" + volumes: + - ${PARSAR_PG_DATA_DIR}:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U parsar -d parsar"] + interval: 5s + timeout: 3s + retries: 20 + start_period: 10s + + parsar-init: + image: ${PARSAR_SERVER_IMAGE} + depends_on: + postgres: + condition: service_healthy + environment: + DATABASE_URL: postgres://parsar:${PARSAR_PG_PASSWORD}@postgres:5432/parsar?sslmode=disable + entrypoint: ["/bin/sh", "-c"] + command: + - | + set -e + parsar-migrate + echo "[parsar-init] migrations complete; create the first owner in the web setup flow" + restart: "no" + + parsar-server: + image: ${PARSAR_SERVER_IMAGE} + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + parsar-init: + condition: service_completed_successfully + ports: + - "${PARSAR_BIND_ADDR}:${PARSAR_LOCAL_PORT}:8080" + environment: + DATABASE_URL: postgres://parsar:${PARSAR_PG_PASSWORD}@postgres:5432/parsar?sslmode=disable + http_proxy: ${HTTP_PROXY:-} + https_proxy: ${HTTPS_PROXY:-} + no_proxy: localhost,127.0.0.1,postgres,parsar-server + PARSAR_ADDR: ":8080" + PARSAR_DATA_DIR: /var/lib/parsar + PARSAR_PUBLIC_URL: ${PARSAR_PUBLIC_URL} + PARSAR_COOKIE_SECURE: ${PARSAR_COOKIE_SECURE} + PARSAR_MASTER_KEY: ${PARSAR_MASTER_KEY} + PARSAR_AGENT_DAEMON_OWNER_URL: http://parsar-server:8080 + PARSAR_FEISHU_MOCK: ${PARSAR_FEISHU_MOCK:-} + PARSAR_FEISHU_APP_ID: ${PARSAR_FEISHU_APP_ID:-} + PARSAR_FEISHU_APP_SECRET: ${PARSAR_FEISHU_APP_SECRET:-} + PARSAR_FEISHU_REDIRECT_URI: ${PARSAR_FEISHU_REDIRECT_URI:-} + PARSAR_FEISHU_VERIFICATION_TOKEN: ${PARSAR_FEISHU_VERIFICATION_TOKEN:-} + PARSAR_FEISHU_ENCRYPT_KEY: ${PARSAR_FEISHU_ENCRYPT_KEY:-} + PARSAR_FEISHU_APP_REGISTRATION: ${PARSAR_FEISHU_APP_REGISTRATION:-} + PARSAR_FEISHU_WEBSOCKET: ${PARSAR_FEISHU_WEBSOCKET:-} + PARSAR_FEISHU_OUTBOUND: ${PARSAR_FEISHU_OUTBOUND:-} + PARSAR_SLACK_CONNECTORS: ${PARSAR_SLACK_CONNECTORS:-} + PARSAR_DISCORD_CONNECTORS: ${PARSAR_DISCORD_CONNECTORS:-} + PARSAR_SLACK_SOCKET: ${PARSAR_SLACK_SOCKET:-} + PARSAR_DISCORD_GATEWAY: ${PARSAR_DISCORD_GATEWAY:-} +EOF + + if [ "$enable_sandbox" = "true" ]; then + cat >>"$compose_file" <<'EOF' + AGENT_DAEMON_SANDBOX_BACKEND: docker + AGENT_DAEMON_SANDBOX_DOCKER_IMAGE: ${PARSAR_SANDBOX_IMAGE} + AGENT_DAEMON_SANDBOX_DOCKER_NETWORK: ${PARSAR_PROJECT_NAME}_default + AGENT_DAEMON_SANDBOX_SERVER_URL: http://parsar-server:8080 + group_add: + - "${DOCKER_GID}" + volumes: + - ${PARSAR_DATA_DIR}:/var/lib/parsar + - /var/run/docker.sock:/var/run/docker.sock + - ${PARSAR_DOCKER_BIN}:/usr/bin/docker:ro +EOF + else + cat >>"$compose_file" <<'EOF' + volumes: + - ${PARSAR_DATA_DIR}:/var/lib/parsar +EOF + fi + + cat >>"$compose_file" <<'EOF' + healthcheck: + test: + - CMD-SHELL + - wget -qO- --tries=1 --timeout=3 http://127.0.0.1:8080/healthz >/dev/null + interval: 10s + timeout: 3s + start_period: 15s + retries: 3 +EOF +} + +wait_for_health() { + for _ in $(seq 1 60); do + if compose_run exec -T parsar-server wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + return 1 +} + +home_arg="${PARSAR_HOME:-$HOME/.parsar}" +server_image="${PARSAR_SERVER_IMAGE:-$DEFAULT_SERVER_IMAGE}" +sandbox_image="${PARSAR_SANDBOX_IMAGE:-$DEFAULT_SANDBOX_IMAGE}" +local_port="${PARSAR_LOCAL_PORT:-18080}" +pg_port="${PARSAR_PG_PORT:-15432}" +bind_addr="${PARSAR_BIND_ADDR:-127.0.0.1}" +public_url="${PARSAR_PUBLIC_URL:-}" +project_name="${PARSAR_PROJECT_NAME:-parsar}" +dry_run="false" +no_sandbox="${PARSAR_NO_SANDBOX:-}" + +while [ "$#" -gt 0 ]; do + case "$1" in + --home) home_arg="${2:?missing value for --home}"; shift 2 ;; + --image) server_image="${2:?missing value for --image}"; shift 2 ;; + --sandbox-image) sandbox_image="${2:?missing value for --sandbox-image}"; shift 2 ;; + --port) local_port="${2:?missing value for --port}"; shift 2 ;; + --pg-port) pg_port="${2:?missing value for --pg-port}"; shift 2 ;; + --bind) bind_addr="${2:?missing value for --bind}"; shift 2 ;; + --public-url) public_url="${2:?missing value for --public-url}"; shift 2 ;; + --project-name) project_name="${2:?missing value for --project-name}"; shift 2 ;; + --no-sandbox) no_sandbox="true"; shift ;; + --dry-run) dry_run="true"; shift ;; + --help|-h) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +case "$local_port" in (*[!0-9]*|"") die "--port must be numeric" ;; esac +case "$pg_port" in (*[!0-9]*|"") die "--pg-port must be numeric" ;; esac +case "$project_name" in + ""|[!a-z0-9]*) die "--project-name must start with a lowercase letter or digit" ;; +esac +case "$project_name" in + *[!a-z0-9_-]*) die "--project-name may contain only lowercase letters, digits, underscores, and dashes" ;; +esac + +parsar_home="$(expand_home_path "$home_arg")" +if [ -z "$public_url" ]; then + public_host="127.0.0.1" + if [ "$bind_addr" != "127.0.0.1" ] && [ "$bind_addr" != "localhost" ]; then + public_host="$bind_addr" + fi + public_url="http://${public_host}:${local_port}" +fi + +enable_sandbox="true" +if [ "$no_sandbox" = "true" ] || [ "$no_sandbox" = "1" ]; then + enable_sandbox="false" +fi +if [ "$(uname -s)" != "Linux" ]; then + enable_sandbox="false" + log "Docker-managed sandboxes are disabled on non-Linux hosts by default" +fi +if [ ! -S /var/run/docker.sock ]; then + enable_sandbox="false" + log "Docker-managed sandboxes are disabled because /var/run/docker.sock is unavailable" +fi + +detect_docker + +docker_bin="$(command -v docker || true)" +if [ "$enable_sandbox" = "true" ] && [ -z "$docker_bin" ]; then + enable_sandbox="false" + log "Docker-managed sandboxes are disabled because docker is not on PATH" +fi + +docker_gid="999" +if [ -S /var/run/docker.sock ] && command -v stat >/dev/null 2>&1; then + docker_gid="$(stat -c '%g' /var/run/docker.sock 2>/dev/null || printf '999')" +fi + +umask 077 +mkdir -p "$parsar_home" "$parsar_home/postgres" "$parsar_home/data" + +env_file="$parsar_home/.env" +compose_file="$parsar_home/compose.yml" +if [ ! -f "$env_file" ]; then + : >"$env_file" +fi + +set_env "PARSAR_HOME" "$parsar_home" "$env_file" +set_env "PARSAR_PROJECT_NAME" "$project_name" "$env_file" +set_env "PARSAR_SERVER_IMAGE" "$server_image" "$env_file" +set_env "PARSAR_SANDBOX_IMAGE" "$sandbox_image" "$env_file" +set_env "PARSAR_LOCAL_PORT" "$local_port" "$env_file" +set_env "PARSAR_PG_PORT" "$pg_port" "$env_file" +set_env "PARSAR_BIND_ADDR" "$bind_addr" "$env_file" +set_env "PARSAR_PUBLIC_URL" "$public_url" "$env_file" +set_env "PARSAR_COOKIE_SECURE" "false" "$env_file" +ensure_env "PARSAR_PG_PASSWORD" "$(random_hex 24)" "$env_file" +ensure_env "PARSAR_MASTER_KEY" "$(random_hex 32)" "$env_file" +set_env "PARSAR_PG_DATA_DIR" "$parsar_home/postgres" "$env_file" +set_env "PARSAR_DATA_DIR" "$parsar_home/data" "$env_file" +set_env "PARSAR_DOCKER_BIN" "${docker_bin:-/usr/bin/docker}" "$env_file" +set_env "DOCKER_GID" "$docker_gid" "$env_file" + +write_compose "$compose_file" "$enable_sandbox" + +log "Wrote $compose_file" +log "Wrote $env_file" +compose_run config --quiet + +if [ "$dry_run" = "true" ]; then + log "Dry run complete; containers were not started" + exit 0 +fi + +log "Starting Parsar with Docker Compose" +compose_run up -d --remove-orphans + +if [ "$enable_sandbox" = "true" ]; then + if ! docker_run pull "$sandbox_image" >/dev/null 2>&1; then + log "Could not pre-pull sandbox image $sandbox_image; managed sandboxes may pull or fail on first use" + fi +fi + +if wait_for_health; then + log "Parsar is healthy" +else + compose_run ps >&2 || true + compose_run logs --tail 120 parsar-server >&2 || true + die "parsar-server did not become healthy" +fi + +cat </dev/null 2>&1; then + pg_connect_ready=1 + break + fi + sleep 1 +done + +if [[ "$pg_connect_ready" -ne 1 ]]; then + echo "Postgres accepted readiness probes but not SQL connections within 30s" >&2 + docker compose -f docker-compose.dev.yml logs --tail=80 postgres >&2 || true + exit 1 +fi + ( cd server DATABASE_URL="$PARSAR_CHECK_DATABASE_URL" PARSAR_MIGRATIONS_DIR="$PWD/migrations" go run ./cmd/migrate diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 3ac6923..1a9406d 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -7,9 +7,8 @@ // // @title Parsar API // @version 0.1.0 -// @description Parsar harness API. Feishu production auth/event deployments require -// @description PARSAR_FEISHU_APP_ID, PARSAR_FEISHU_APP_SECRET, PARSAR_FEISHU_REDIRECT_URI -// @description and PARSAR_FEISHU_VERIFICATION_TOKEN when PARSAR_FEISHU_MOCK is not true. +// @description Parsar harness API. Optional Feishu SSO and event delivery +// @description are enabled only when the corresponding PARSAR_FEISHU_* values are configured. // @license.name Apache 2.0 // @license.url https://www.apache.org/licenses/LICENSE-2.0.html // @BasePath / @@ -82,20 +81,19 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) -const feishuStartupFatalMessage = "PARSAR_FEISHU_APP_ID/APP_SECRET/REDIRECT_URI and PARSAR_FEISHU_VERIFICATION_TOKEN required when not in mock mode; export PARSAR_FEISHU_MOCK=true for local dev" - type feishuStartupMode string const ( - feishuStartupModeDev feishuStartupMode = "dev" - feishuStartupModeProd feishuStartupMode = "prod" + feishuStartupModeDisabled feishuStartupMode = "disabled" + feishuStartupModeMock feishuStartupMode = "mock" + feishuStartupModeProd feishuStartupMode = "prod" ) type feishuStartupDecision struct { - Mode feishuStartupMode - RegisterHandlers bool - CookieSecureWarning bool - FatalMessage string + Mode feishuStartupMode + RegisterOAuthHandlers bool + RegisterWebhookSecurity bool + CookieSecureWarning bool } func main() { @@ -192,21 +190,19 @@ func main() { } } - // Validate Feishu deployment mode before DB wiring so a - // misconfigured production process cannot come up with missing - // login/event routes. feishuDecision := decideFeishuStartup(envLookup) - if feishuDecision.FatalMessage != "" { - log.Bg().Error(feishuDecision.FatalMessage) - os.Exit(1) - } - if feishuDecision.Mode == feishuStartupModeDev { + switch feishuDecision.Mode { + case feishuStartupModeMock: log.Bg().Warn("DEV MODE: PARSAR_FEISHU_MOCK=true — using mock Feishu auth and skipping webhook verification") - } else { - log.Bg().Info("feishu prod mode: real Feishu auth/event configuration present") + case feishuStartupModeProd: + log.Bg().Info("feishu prod mode configured", + "oauth_handlers", feishuDecision.RegisterOAuthHandlers, + "webhook_security", feishuDecision.RegisterWebhookSecurity) if feishuDecision.CookieSecureWarning { log.Bg().Warn("running prod auth on HTTP — cookies will leak") } + default: + log.Bg().Info("feishu auth/event routes disabled; email/password setup remains available") } pool := openPool(cfg.Database.URL) @@ -358,19 +354,22 @@ func main() { opts := []dev.RouterOption{ dev.WithDispatchContext(serverRootCtx), dev.WithRunStreamBroker(runStreamBroker), + dev.WithAuthProviders(buildAuthProviderRegistry(envLookup, cfg, feishuDecision)), + } + if truthy(envLookup("PARSAR_FEISHU_APP_REGISTRATION")) { + if regClient, err := gatewaypkg.NewFeishuAppRegistrationClient(gatewaypkg.FeishuAppRegistrationClientOptions{ + AccountsBaseURL: strings.TrimSpace(envLookup(feishu.EnvAuthorizeBase)), + OpenBaseURL: strings.TrimSpace(envLookup(feishu.EnvAPIBase)), + }); err != nil { + log.Bg().Warn("feishu app-registration client not registered", "error", err) + } else { + opts = append(opts, dev.WithFeishuAppRegistration(regClient, strings.TrimSpace(envLookup(feishu.EnvAPIBase)))) + log.Bg().Info("feishu app-registration provisioning registered") + } } - if regClient, err := gatewaypkg.NewFeishuAppRegistrationClient(gatewaypkg.FeishuAppRegistrationClientOptions{ - AccountsBaseURL: strings.TrimSpace(envLookup(feishu.EnvAuthorizeBase)), - OpenBaseURL: strings.TrimSpace(envLookup(feishu.EnvAPIBase)), - }); err != nil { - log.Bg().Warn("feishu app-registration client not registered", "error", err) - } else { - opts = append(opts, dev.WithFeishuAppRegistration(regClient, strings.TrimSpace(envLookup(feishu.EnvAPIBase)))) - log.Bg().Info("feishu app-registration provisioning registered") - } - if feishuDecision.RegisterHandlers { + if feishuDecision.RegisterWebhookSecurity { opts = append(opts, dev.WithFeishuWebhookSecurity( - feishuDecision.Mode == feishuStartupModeDev, + feishuDecision.Mode == feishuStartupModeMock, strings.TrimSpace(envLookup(feishu.EnvVerificationToken)), strings.TrimSpace(envLookup(feishu.EnvEncryptKey)), )) @@ -640,7 +639,7 @@ func main() { CookieSecure: cfg.Auth.Cookie.Secure, })) - if feishuDecision.RegisterHandlers { + if feishuDecision.RegisterOAuthHandlers { feishuClient, err := feishu.NewClientFromEnv(envLookup) if err != nil { log.Bg().Warn("feishu OIDC client not registered", "error", err) @@ -1047,19 +1046,80 @@ func decideFeishuStartup(env func(string) string) feishuStartupDecision { env = os.Getenv } if feishu.IsMockEnabled(env) { - return feishuStartupDecision{Mode: feishuStartupModeDev, RegisterHandlers: true} + return feishuStartupDecision{ + Mode: feishuStartupModeMock, + RegisterOAuthHandlers: true, + RegisterWebhookSecurity: true, + } } - decision := feishuStartupDecision{ - Mode: feishuStartupModeProd, - RegisterHandlers: true, - CookieSecureWarning: !strings.EqualFold(strings.TrimSpace(env("PARSAR_COOKIE_SECURE")), "true"), + oauthConfigured := feishu.IsConfigured(env) + webhookConfigured := feishu.IsWebhookConfigured(env) + if !oauthConfigured && !webhookConfigured { + return feishuStartupDecision{Mode: feishuStartupModeDisabled} } - if !feishu.IsConfigured(env) || !feishu.IsWebhookConfigured(env) { - decision.RegisterHandlers = false - decision.CookieSecureWarning = false - decision.FatalMessage = feishuStartupFatalMessage + return feishuStartupDecision{ + Mode: feishuStartupModeProd, + RegisterOAuthHandlers: oauthConfigured, + RegisterWebhookSecurity: webhookConfigured, + CookieSecureWarning: oauthConfigured && !strings.EqualFold(strings.TrimSpace(env("PARSAR_COOKIE_SECURE")), "true"), + } +} + +func buildAuthProviderRegistry(env func(string) string, cfg config.Config, feishuDecision feishuStartupDecision) dev.AuthProviderRegistry { + if env == nil { + env = os.Getenv + } + providers := []dev.AuthProvider{{ + ID: "password", + Type: dev.AuthProviderTypePassword, + Label: "Email password", + Enabled: true, + Configured: true, + LoginURL: "/login", + }} + + required := []string{ + feishu.EnvAppID, + feishu.EnvAppSecret, + feishu.EnvRedirectURI, + } + missing := missingEnv(env, required) + configured := feishuDecision.RegisterOAuthHandlers + if feishu.IsMockEnabled(env) { + missing = nil + configured = true + } + callbackURL := strings.TrimSpace(env(feishu.EnvRedirectURI)) + if callbackURL == "" { + callbackURL = cfg.BuildPublicURL("/api/v1/auth/feishu/callback") + } + feishuProvider := dev.AuthProvider{ + ID: "feishu", + Type: dev.AuthProviderTypeOAuth, + Label: "Feishu", + Enabled: feishuDecision.RegisterOAuthHandlers, + Configured: configured, + CallbackURL: callbackURL, + RequiredEnv: required, + MissingEnv: missing, + DocsURL: "docs/deploy/feishu-prod.md", + } + if feishuProvider.Enabled { + feishuProvider.LoginURL = "/api/v1/auth/feishu/start" + } + providers = append(providers, feishuProvider) + + return dev.AuthProviderRegistry{Providers: providers} +} + +func missingEnv(env func(string) string, names []string) []string { + missing := make([]string, 0, len(names)) + for _, name := range names { + if strings.TrimSpace(env(name)) == "" { + missing = append(missing, name) + } } - return decision + return missing } func drainAudit(ing *audit.Ingester) { diff --git a/server/cmd/server/main_test.go b/server/cmd/server/main_test.go index bc50910..f97060e 100644 --- a/server/cmd/server/main_test.go +++ b/server/cmd/server/main_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/MiniMax-AI-Dev/parsar/server/internal/auth/feishu" + "github.com/MiniMax-AI-Dev/parsar/server/internal/config" ) func TestDecideFeishuStartup(t *testing.T) { @@ -13,7 +14,8 @@ func TestDecideFeishuStartup(t *testing.T) { name string env map[string]string wantMode feishuStartupMode - wantFatal bool + wantOAuth bool + wantWebhook bool wantWarning bool }{ { @@ -21,35 +23,44 @@ func TestDecideFeishuStartup(t *testing.T) { env: map[string]string{ feishu.EnvMock: "true", }, - wantMode: feishuStartupModeDev, + wantMode: feishuStartupModeMock, + wantOAuth: true, + wantWebhook: true, }, { - name: "prod missing config", - env: map[string]string{}, - wantMode: feishuStartupModeProd, - wantFatal: true, + name: "missing config disables optional feishu routes", + env: map[string]string{}, + wantMode: feishuStartupModeDisabled, }, { - name: "prod configured warns when cookie not secure", + name: "prod oauth configured warns when cookie not secure", env: map[string]string{ - feishu.EnvAppID: "cli_x", - feishu.EnvAppSecret: "secret", - feishu.EnvRedirectURI: "https://parsar.example/api/v1/auth/feishu/callback", - feishu.EnvVerificationToken: "verify-token", + feishu.EnvAppID: "cli_x", + feishu.EnvAppSecret: "secret", + feishu.EnvRedirectURI: "https://parsar.example/api/v1/auth/feishu/callback", }, wantMode: feishuStartupModeProd, + wantOAuth: true, wantWarning: true, }, { - name: "prod configured secure cookies", + name: "prod oauth configured secure cookies", + env: map[string]string{ + feishu.EnvAppID: "cli_x", + feishu.EnvAppSecret: "secret", + feishu.EnvRedirectURI: "https://parsar.example/api/v1/auth/feishu/callback", + "PARSAR_COOKIE_SECURE": "true", + }, + wantMode: feishuStartupModeProd, + wantOAuth: true, + }, + { + name: "prod webhook-only config enables webhook security", env: map[string]string{ - feishu.EnvAppID: "cli_x", - feishu.EnvAppSecret: "secret", - feishu.EnvRedirectURI: "https://parsar.example/api/v1/auth/feishu/callback", feishu.EnvVerificationToken: "verify-token", - "PARSAR_COOKIE_SECURE": "true", }, - wantMode: feishuStartupModeProd, + wantMode: feishuStartupModeProd, + wantWebhook: true, }, } for _, tc := range cases { @@ -58,11 +69,11 @@ func TestDecideFeishuStartup(t *testing.T) { if got.Mode != tc.wantMode { t.Fatalf("Mode = %q, want %q", got.Mode, tc.wantMode) } - if (got.FatalMessage != "") != tc.wantFatal { - t.Fatalf("FatalMessage = %q, want fatal=%v", got.FatalMessage, tc.wantFatal) + if got.RegisterOAuthHandlers != tc.wantOAuth { + t.Fatalf("RegisterOAuthHandlers = %v, want %v", got.RegisterOAuthHandlers, tc.wantOAuth) } - if got.RegisterHandlers == tc.wantFatal { - t.Fatalf("RegisterHandlers = %v, want fatal inverse", got.RegisterHandlers) + if got.RegisterWebhookSecurity != tc.wantWebhook { + t.Fatalf("RegisterWebhookSecurity = %v, want %v", got.RegisterWebhookSecurity, tc.wantWebhook) } if got.CookieSecureWarning != tc.wantWarning { t.Fatalf("CookieSecureWarning = %v, want %v", got.CookieSecureWarning, tc.wantWarning) @@ -71,6 +82,79 @@ func TestDecideFeishuStartup(t *testing.T) { } } +func TestBuildAuthProviderRegistry(t *testing.T) { + cfg := config.Config{Server: config.ServerConfig{PublicURL: "https://parsar.example"}} + cases := []struct { + name string + env map[string]string + wantFeishuEnable bool + wantMissing []string + }{ + { + name: "default password only and feishu diagnostic disabled", + env: map[string]string{}, + wantMissing: []string{ + feishu.EnvAppID, + feishu.EnvAppSecret, + feishu.EnvRedirectURI, + }, + }, + { + name: "feishu oauth configured", + env: map[string]string{ + feishu.EnvAppID: "cli_x", + feishu.EnvAppSecret: "secret", + feishu.EnvRedirectURI: "https://parsar.example/api/v1/auth/feishu/callback", + }, + wantFeishuEnable: true, + }, + { + name: "mock feishu configured", + env: map[string]string{ + feishu.EnvMock: "true", + }, + wantFeishuEnable: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + env := func(k string) string { return tc.env[k] } + registry := buildAuthProviderRegistry(env, cfg, decideFeishuStartup(env)) + if len(registry.Providers) != 2 { + t.Fatalf("provider count = %d, want 2", len(registry.Providers)) + } + if registry.Providers[0].ID != "password" || !registry.Providers[0].Enabled { + t.Fatalf("password provider = %+v, want enabled password", registry.Providers[0]) + } + feishuProvider := registry.Providers[1] + if feishuProvider.ID != "feishu" { + t.Fatalf("second provider id = %q, want feishu", feishuProvider.ID) + } + if feishuProvider.Enabled != tc.wantFeishuEnable { + t.Fatalf("feishu enabled = %v, want %v", feishuProvider.Enabled, tc.wantFeishuEnable) + } + if !equalStringSlices(feishuProvider.MissingEnv, tc.wantMissing) { + t.Fatalf("feishu missing env = %#v, want %#v", feishuProvider.MissingEnv, tc.wantMissing) + } + if feishuProvider.CallbackURL == "" { + t.Fatal("feishu callback URL must be populated for admin diagnostics") + } + }) + } +} + +func equalStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + // TestFanoutEndpointHost guards host-only output for the "audit OTLP // fan-out wired" startup log so an internal collector URL does not show // up in INFO logs. Unparseable input must still produce a non-empty label. diff --git a/server/cmd/server/sandbox_docker.go b/server/cmd/server/sandbox_docker.go index fb93a2b..4af0675 100644 --- a/server/cmd/server/sandbox_docker.go +++ b/server/cmd/server/sandbox_docker.go @@ -49,6 +49,9 @@ func resolveAgentDaemonPublicWSURL(env func(string) string, cfg config.Config) s if env == nil { env = os.Getenv } + if serverURL := strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_SERVER_URL")); serverURL != "" { + return agentDaemonWSURLFromBase(serverURL) + } base := buildAgentDaemonWSURL(cfg) if strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_BACKEND")) != "docker" { return base @@ -60,6 +63,23 @@ func resolveAgentDaemonPublicWSURL(env func(string) string, cfg config.Config) s return rewritten } +func agentDaemonWSURLFromBase(base string) string { + const path = "/agent-daemon/ws" + raw := strings.TrimRight(strings.TrimSpace(base), "/") + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" { + return "ws://" + raw + path + } + switch strings.ToLower(parsed.Scheme) { + case "https", "wss": + parsed.Scheme = "wss" + default: + parsed.Scheme = "ws" + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + path + return parsed.String() +} + // buildDockerAgentDaemonSandboxProvider wires a local-docker-backed // SandboxProvider for the agent_daemon connector. Returns nil when the // docker backend is not requested (caller falls back to the e2b builder, @@ -107,6 +127,10 @@ func buildDockerAgentDaemonSandboxProvider( serverURL = publicURL hostGateway = false } + if override := strings.TrimRight(strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_SERVER_URL")), "/"); override != "" { + serverURL = override + hostGateway = false + } client := dockerClientFromEnv(env, image, network, hostGateway) provider, err := connagentdaemon.NewE2BSandboxProvider(connagentdaemon.E2BProviderConfig{ diff --git a/server/cmd/server/sandbox_docker_test.go b/server/cmd/server/sandbox_docker_test.go index d3c5c8c..4a5f8d1 100644 --- a/server/cmd/server/sandbox_docker_test.go +++ b/server/cmd/server/sandbox_docker_test.go @@ -116,6 +116,21 @@ func TestResolveAgentDaemonPublicWSURLKeepsLoopbackWhenDockerNetworkSet(t *testi } } +func TestResolveAgentDaemonPublicWSURLUsesSandboxServerURL(t *testing.T) { + var cfg config.Config + cfg.Server.PublicURL = "http://127.0.0.1:18090" + env := dockerBackendEnv(map[string]string{ + "AGENT_DAEMON_SANDBOX_BACKEND": "docker", + "AGENT_DAEMON_SANDBOX_DOCKER_NETWORK": "parsar_default", + "AGENT_DAEMON_SANDBOX_SERVER_URL": "http://parsar-server:8080", + }) + + got := resolveAgentDaemonPublicWSURL(env, cfg) + if want := "ws://parsar-server:8080/agent-daemon/ws"; got != want { + t.Fatalf("resolveAgentDaemonPublicWSURL = %q, want %q", got, want) + } +} + func TestResolveAgentDaemonPublicWSURLLeavesNonLoopbackUntouched(t *testing.T) { // A real public host is reachable from inside the container as-is; only the // scheme swap from buildAgentDaemonWSURL applies. diff --git a/server/internal/audit/ingester.go b/server/internal/audit/ingester.go index 4de8196..e1a6587 100644 --- a/server/internal/audit/ingester.go +++ b/server/internal/audit/ingester.go @@ -56,6 +56,7 @@ type Ingester struct { closing atomic.Bool emitted atomic.Int64 + handled atomic.Int64 dropped atomic.Int64 sinkErrors atomic.Int64 lastLagNanos atomic.Int64 @@ -157,20 +158,12 @@ func (i *Ingester) Flush(ctx context.Context) error { if i.closing.Load() { return ErrClosed } + target := i.emitted.Load() tick := time.NewTicker(5 * time.Millisecond) defer tick.Stop() for { - // Buffer empty + a short settling window for the worker's - // in-flight Write is good enough without exposing more state. - if len(i.buffer) == 0 { - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(20 * time.Millisecond): - } - if len(i.buffer) == 0 { - return nil - } + if i.handled.Load() >= target { + return nil } select { case <-ctx.Done(): @@ -193,6 +186,7 @@ func (i *Ingester) run(ctx context.Context) { return } i.handle(ctx, ev) + i.handled.Add(1) } } } @@ -207,6 +201,7 @@ func (i *Ingester) drain(ctx context.Context) { return } i.handle(ctx, ev) + i.handled.Add(1) default: return } diff --git a/server/internal/audit/ingester_test.go b/server/internal/audit/ingester_test.go index ff28c54..63e4dec 100644 --- a/server/internal/audit/ingester_test.go +++ b/server/internal/audit/ingester_test.go @@ -121,6 +121,25 @@ func TestIngesterStopWaitsForWorker(t *testing.T) { } } +func TestIngesterFlushWaitsForInFlightWrite(t *testing.T) { + sink := &fakeSink{delay: 75 * time.Millisecond} + ing := NewIngester(sink, Options{BufferCapacity: 4, WriteTimeout: time.Second}) + ing.Start(context.Background()) + defer func() { _ = ing.Stop(context.Background()) }() + + if err := ing.Emit(Event{Source: SourceAdmin, EventType: "test.flush", ActorType: ActorTypeSystem}); err != nil { + t.Fatalf("emit: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := ing.Flush(ctx); err != nil { + t.Fatalf("flush: %v", err) + } + if got := len(sink.Events()); got != 1 { + t.Fatalf("Flush returned before in-flight sink write completed; got %d events", got) + } +} + func TestIngesterCountsSinkErrors(t *testing.T) { sink := &fakeSink{errOnce: errors.New("boom")} ing := NewIngester(sink, Options{BufferCapacity: 4}) diff --git a/server/internal/dev/auth_providers.go b/server/internal/dev/auth_providers.go new file mode 100644 index 0000000..a91a0a9 --- /dev/null +++ b/server/internal/dev/auth_providers.go @@ -0,0 +1,126 @@ +package dev + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi/v5" +) + +const ( + AuthProviderTypePassword = "password" + AuthProviderTypeOAuth = "oauth" + AuthProviderTypeOIDC = "oidc" + AuthProviderTypeSAML = "saml" +) + +type AuthProvider struct { + ID string `json:"id"` + Type string `json:"type"` + Label string `json:"label"` + Enabled bool `json:"enabled"` + LoginURL string `json:"login_url,omitempty"` + Configured bool `json:"configured"` + CallbackURL string `json:"callback_url,omitempty"` + RequiredEnv []string `json:"required_env,omitempty"` + MissingEnv []string `json:"missing_env,omitempty"` + DocsURL string `json:"docs_url,omitempty"` +} + +type AuthProviderRegistry struct { + Providers []AuthProvider +} + +type publicAuthProvider struct { + ID string `json:"id"` + Type string `json:"type"` + Label string `json:"label"` + Enabled bool `json:"enabled"` + LoginURL string `json:"login_url,omitempty"` +} + +type authProvidersResponse struct { + Providers []publicAuthProvider `json:"providers"` +} + +type workspaceAuthProvidersResponse struct { + WorkspaceID string `json:"workspace_id"` + Providers []AuthProvider `json:"providers"` +} + +func (r AuthProviderRegistry) publicProviders() []publicAuthProvider { + providers := r.providersOrDefault() + out := make([]publicAuthProvider, 0, len(providers)) + for _, p := range providers { + entry := publicAuthProvider{ + ID: p.ID, + Type: p.Type, + Label: p.Label, + Enabled: p.Enabled, + } + if p.Enabled { + entry.LoginURL = p.LoginURL + } + out = append(out, entry) + } + return out +} + +func (r AuthProviderRegistry) providersOrDefault() []AuthProvider { + if len(r.Providers) > 0 { + return r.Providers + } + return []AuthProvider{{ + ID: "password", + Type: AuthProviderTypePassword, + Label: "Email password", + Enabled: true, + Configured: true, + LoginURL: "/login", + }} +} + +// listAuthProviders returns login methods visible before authentication. +// +// @Summary List auth providers +// @Description Returns the login providers the public login page may render. Sensitive diagnostics are omitted. +// @Tags auth +// @ID listAuthProviders +// @Produce json +// @Success 200 {object} authProvidersResponse "Auth providers" +// @Router /api/v1/auth/providers [get] +func listAuthProviders(registry AuthProviderRegistry) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, authProvidersResponse{Providers: registry.publicProviders()}) + } +} + +// listWorkspaceAuthProviders returns admin-visible auth provider diagnostics. +// +// @Summary List workspace auth provider diagnostics +// @Description Returns read-only auth provider status for workspace owners/admins. Secret values are never returned. +// @Tags auth +// @ID listWorkspaceAuthProviders +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Success 200 {object} workspaceAuthProvidersResponse "Auth provider diagnostics" +// @Failure 400 {object} map[string]string "workspace_id must be a valid uuid" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Router /api/v1/workspaces/{workspaceID}/auth/providers [get] +func listWorkspaceAuthProviders(runtimeStore RuntimeStore, registry AuthProviderRegistry) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + writeJSON(w, http.StatusOK, workspaceAuthProvidersResponse{ + WorkspaceID: workspaceID, + Providers: registry.providersOrDefault(), + }) + } +} diff --git a/server/internal/dev/routes.go b/server/internal/dev/routes.go index bf3352e..e02de8b 100644 --- a/server/internal/dev/routes.go +++ b/server/internal/dev/routes.go @@ -241,6 +241,7 @@ type routerConfig struct { connectorRegistry *connector.Registry authMiddleware *auth.Middleware oauthDeps *OAuthHandlerDeps + authProviders AuthProviderRegistry githubConnectionDeps *GitHubConnectionDeps feishuWebhook feishuWebhookConfig feishuRegistration feishuRegistrationConfig @@ -339,6 +340,13 @@ func WithOAuthHandlers(deps OAuthHandlerDeps) RouterOption { } } +// WithAuthProviders wires read-only auth provider discovery and diagnostics. +func WithAuthProviders(registry AuthProviderRegistry) RouterOption { + return func(cfg *routerConfig) { + cfg.authProviders = registry + } +} + // WithGitHubConnectionHandlers wires the personal GitHub OAuth connection // flow. Missing deps keep the routes disabled without affecting login. func WithGitHubConnectionHandlers(deps GitHubConnectionDeps) RouterOption { @@ -553,6 +561,7 @@ func RegisterRoutesWithStore(r chi.Router, runtimeStore RuntimeStore, opts ...Ro r.Get("/auth/feishu/callback", feishuCallbackHandler(*cfg.oauthDeps)) r.Post("/auth/logout", authLogoutHandler(*cfg.oauthDeps)) } + r.Get("/auth/providers", listAuthProviders(cfg.authProviders)) }) r.Group(func(r chi.Router) { @@ -685,6 +694,7 @@ func RegisterRoutesWithStore(r chi.Router, runtimeStore RuntimeStore, opts ...Ro r.Post("/workspaces/{workspaceID}/join-requests/{requestID}/reject", rejectJoinRequest(runtimeStore)) r.Get("/workspaces/{workspaceID}/settings", getWorkspaceSettings(runtimeStore)) r.Patch("/workspaces/{workspaceID}/settings", patchWorkspaceSettings(runtimeStore)) + r.Get("/workspaces/{workspaceID}/auth/providers", listWorkspaceAuthProviders(runtimeStore, cfg.authProviders)) r.Post("/workspaces/{workspaceID}/agents", createAgent(runtimeStore, cfg.agentDaemonSandbox)) r.Post("/workspaces", createWorkspace(runtimeStore)) r.Patch("/workspaces/{workspaceID}", updateWorkspace(runtimeStore))