feat(vcs): gate self-hosted Git providers to self-host deployments only (MUL-3772)

The Forgejo/Gitea/GitLab integration is intended for self-hosted Multica, where
Multica can reach a Git instance on the operator's own network. On the managed
multi-tenant cloud it adds an SSRF surface (connect validates a user-supplied
instance URL from the server) and would store third-party Git tokens for all
tenants under one key, while only serving the small subset of users whose
instance is publicly reachable. Product decision: offer it on self-host only.

- Add an explicit deployment switch MULTICA_VCS_INTEGRATION_ENABLED (default
  off). Connect, rotate, and webhook now require BOTH the switch on AND a valid
  MULTICA_VCS_SECRET_KEY — the switch is the product boundary, not key presence
  alone. When off, connect/rotate return 404 and the webhook returns a bare 404
  (no config leak), independent of the frontend.
- /api/config exposes vcs_integration_available (mirrors the switch, omitted
  when false) so the Settings UI hides the whole "Git providers" section on
  cloud instead of surfacing an operator-only "missing key" hint.
- docker-compose.selfhost.yml defaults the switch on; .env.example documents it.
- Docs (en/zh) lead with a callout: available on self-hosted Multica only, not
  Multica Cloud, and clarify "self-hosted" means Multica itself, not just Git.

#5006 / #5883 stay in place — the schema and backend capability are retained;
this only gates availability. No cloud VCS connection can exist (connect always
required the key, which the cloud never set), so nothing needs migrating.

Verified: go build/vet + VCS/config handler tests on a fresh migrated DB
(incl. a new disabled-deployment 404 test); pnpm typecheck (core + views) and
the integrations-tab + core schema/config vitest suites pass.

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Bohan-J
2026-07-24 16:07:23 +08:00
parent 07a0a4272d
commit f294bfb0eb
16 changed files with 197 additions and 12 deletions

View File

@@ -270,12 +270,19 @@ GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# Self-hosted Git provider integration (Settings → Integrations): Forgejo, Gitea,
# and GitLab. Off until MULTICA_VCS_SECRET_KEY is set — a base64-encoded
# 32-byte key that encrypts each workspace's access token and webhook secret at
# rest. Leave empty to disable. Generate one with: openssl rand -base64 32
# and GitLab. This is a SELF-HOSTED-MULTICA-ONLY feature (not offered on the
# managed cloud). Two settings gate it, and BOTH are required — connect,
# webhook, and rotate all check both:
# 1. MULTICA_VCS_INTEGRATION_ENABLED=true turns the feature on for this
# deployment. Left unset/false the section is hidden entirely (this is how
# the managed cloud keeps it off). The self-host docker-compose sets it.
# 2. MULTICA_VCS_SECRET_KEY — a base64-encoded 32-byte key that encrypts each
# workspace's access token and webhook secret at rest. Generate one with:
# openssl rand -base64 32
# Unlike GitHub there is no App: each workspace connects its own instance URL
# and access token in the UI, and registers the returned webhook URL/secret on
# its repo or org webhook settings.
MULTICA_VCS_INTEGRATION_ENABLED=
MULTICA_VCS_SECRET_KEY=
# Lark / Feishu bot integration (Settings → Integrations "Bind to Lark")

View File

@@ -5,6 +5,15 @@ description: Connect a self-hosted Forgejo, Gitea, or GitLab instance per worksp
import { Callout } from "fumadocs-ui/components/callout";
<Callout type="warn">
**Self-hosted Multica only.** This integration is available only when you run
Multica yourself; it is not offered on Multica Cloud. "Self-hosted" here means
**Multica** is self-hosted — typically so it can reach a Git instance on your own
network. A server operator must enable it (`MULTICA_VCS_INTEGRATION_ENABLED=true`)
and set `MULTICA_VCS_SECRET_KEY`; until then the section does not appear in
Settings → Integrations.
</Callout>
Multica connects to a self-hosted Git provider per workspace: **Forgejo**, **Gitea**, or **GitLab**. Once connected, any pull request (GitLab: merge request) whose branch name, title, or body contains an issue identifier (for example `MUL-123`) is **auto-linked** to that [issue](/issues), appears under **Pull requests** in the issue sidebar, and — when merged with a closing keyword — moves the issue to **Done**. CI for the head commit shows as a checks bar on the card.
These providers run alongside GitHub: a workspace can use any combination.
@@ -15,7 +24,15 @@ Unlike GitHub, these providers have no "App" model. Each workspace stores its ow
## Prerequisites (server)
Set a base64-encoded 32-byte key so the server can encrypt stored credentials. Without it, the connect form is disabled.
Turn the integration on for this deployment. It is off by default, so the section stays hidden until you set:
```sh
MULTICA_VCS_INTEGRATION_ENABLED=true
```
The official self-hosted `docker compose` file (`docker-compose.selfhost.yml`) sets this for you.
Then set a base64-encoded 32-byte key so the server can encrypt stored credentials. Without it, the connect form is disabled.
```sh
openssl rand -base64 32
@@ -25,6 +42,8 @@ openssl rand -base64 32
MULTICA_VCS_SECRET_KEY=<base64 32-byte key>
```
Both are required: the switch decides whether the feature is offered, and the key encrypts the stored token and webhook secret. Connect, webhook, and rotate all require both.
Set `MULTICA_PUBLIC_URL` to the server's public base URL so Multica can show a ready-to-paste webhook URL. Without it, the UI shows only the webhook path and you prepend your own origin.
## Connect a workspace

View File

@@ -5,6 +5,10 @@ description: 为每个工作区连接自托管的 Forgejo、Gitea 或 GitLab 实
import { Callout } from "fumadocs-ui/components/callout";
<Callout type="warn">
**仅适用于自部署的 Multica。** 此集成只在你**自己部署 Multica** 时可用Multica 云端不提供。这里的「自托管」指的是 **Multica 本身自部署**——通常是为了让它能访问你自己网络内的 Git 实例。需要服务端运维开启(`MULTICA_VCS_INTEGRATION_ENABLED=true`)并设置 `MULTICA_VCS_SECRET_KEY`;在此之前,设置 → 集成里不会出现该板块。
</Callout>
Multica 按工作区连接自托管的 Git 代码托管:**Forgejo**、**Gitea** 或 **GitLab**。连接后,任何在分支名、标题或正文中包含 issue 编号(例如 `MUL-123`)的 Pull RequestGitLab 为 Merge Request都会**自动关联**到对应的 [issue](/issues),出现在 issue 侧栏的 **Pull requests** 中;当带有关闭关键字的 PR 合并后issue 会被移动到「完成」。head commit 的 CI 会以检查条的形式显示在卡片上。
这些代码托管与 GitHub 并行工作:一个工作区可任意组合使用。
@@ -15,7 +19,15 @@ Multica 按工作区连接自托管的 Git 代码托管:**Forgejo**、**Gitea*
## 前置条件(服务端)
设置一个 base64 编码的 32 字节密钥,供服务端加密存储的凭据。未设置时,连接表单将不可用。
先为该部署开启此集成。它默认关闭,未开启时该板块不会出现:
```sh
MULTICA_VCS_INTEGRATION_ENABLED=true
```
官方自部署 `docker compose` 文件(`docker-compose.selfhost.yml`)已为你设置好。
再设置一个 base64 编码的 32 字节密钥,供服务端加密存储的凭据。未设置时,连接表单将不可用。
```sh
openssl rand -base64 32
@@ -25,6 +37,8 @@ openssl rand -base64 32
MULTICA_VCS_SECRET_KEY=<base64 32 字节密钥>
```
两者都必需:开关决定是否提供该功能,密钥用于加密存储的令牌与 Webhook 密钥。连接、Webhook、轮换都要求两者同时满足。
设置 `MULTICA_PUBLIC_URL` 为服务端的公网基础地址Multica 即可直接给出可粘贴的 Webhook 地址;否则界面只显示 Webhook 路径,需自行拼接来源地址。
## 连接工作区

View File

@@ -119,6 +119,11 @@ services:
# in the database, so this single deployment-wide key is all the operator
# needs to set here.
MULTICA_SLACK_SECRET_KEY: ${MULTICA_SLACK_SECRET_KEY:-}
# Self-hosted Git provider integration (Forgejo / Gitea / GitLab). This
# is a self-host-only feature, so the compose file turns it on by default;
# the managed cloud leaves it unset (off). It still needs a valid
# MULTICA_VCS_SECRET_KEY below to actually work.
MULTICA_VCS_INTEGRATION_ENABLED: ${MULTICA_VCS_INTEGRATION_ENABLED:-true}
# VCS integration at-rest encryption key for token-based providers
MULTICA_VCS_SECRET_KEY: ${MULTICA_VCS_SECRET_KEY:-}
restart: unless-stopped

View File

@@ -184,6 +184,10 @@ export interface AppConfigResponse {
daemon_server_url?: string;
daemon_app_url?: string;
workspace_creation_disabled?: boolean;
/** Whether this deployment offers the self-hosted Git provider integration
* (self-host only; off on the managed cloud). Absent/false hides the whole
* Settings → Integrations "Git providers" section. */
vcs_integration_available?: boolean;
feature_flags?: Record<string, boolean>;
server_version?: string;
}
@@ -334,6 +338,7 @@ export const AppConfigSchema = z.object({
daemon_server_url: OptionalStringSchema,
daemon_app_url: OptionalStringSchema,
workspace_creation_disabled: BooleanWithDefaultSchema(false).optional(),
vcs_integration_available: BooleanWithDefaultSchema(false).optional(),
feature_flags: FeatureFlagsSchema,
server_version: OptionalStringSchema,
}).loose();
@@ -346,6 +351,7 @@ export const EMPTY_APP_CONFIG: AppConfigResponse = {
daemon_server_url: "",
daemon_app_url: "",
workspace_creation_disabled: false,
vcs_integration_available: false,
feature_flags: {},
};

View File

@@ -15,6 +15,11 @@ interface ConfigState {
// must be hidden. Defaults to false so unknown / older servers behave like
// the managed-cloud case.
workspaceCreationDisabled: boolean;
// Self-host-only gate for the Git provider integration (Forgejo / Gitea /
// GitLab). When false the whole Settings → Integrations "Git providers"
// section is hidden. Defaults to false so unknown / older servers and the
// managed cloud (which omits the field) keep it hidden.
vcsIntegrationAvailable: boolean;
featureFlags: Record<string, boolean>;
// The running API build version, surfaced in the Help popover so
// self-hosted operators can confirm what's deployed. Empty for dev builds
@@ -25,6 +30,7 @@ interface ConfigState {
allowSignup: boolean;
googleClientId?: string;
workspaceCreationDisabled?: boolean;
vcsIntegrationAvailable?: boolean;
}) => void;
setDaemonConfig: (config: {
daemonServerUrl?: string;
@@ -42,11 +48,16 @@ export const configStore = createStore<ConfigState>((set) => ({
daemonServerUrl: "",
daemonAppUrl: "",
workspaceCreationDisabled: false,
vcsIntegrationAvailable: false,
featureFlags: {},
serverVersion: "",
setCdnConfig: ({ cdnDomain, cdnSigned = false }) => set({ cdnDomain, cdnSigned }),
setAuthConfig: ({ allowSignup, googleClientId = "", workspaceCreationDisabled = false }) =>
set({ allowSignup, googleClientId, workspaceCreationDisabled }),
setAuthConfig: ({
allowSignup,
googleClientId = "",
workspaceCreationDisabled = false,
vcsIntegrationAvailable = false,
}) => set({ allowSignup, googleClientId, workspaceCreationDisabled, vcsIntegrationAvailable }),
setDaemonConfig: ({ daemonServerUrl = "", daemonAppUrl = "" }) =>
set({ daemonServerUrl, daemonAppUrl }),
setFeatureFlags: (flags = {}) => set({ featureFlags: { ...flags } }),

View File

@@ -62,6 +62,8 @@ export function AuthInitializer({
// Old servers omit this field — treat that as "creation allowed"
// (the managed-cloud default) rather than blocking the UI.
workspaceCreationDisabled: cfg.workspace_creation_disabled === true,
// Absent/false on the managed cloud and older servers → section hidden.
vcsIntegrationAvailable: cfg.vcs_integration_available === true,
});
configStore.getState().setDaemonConfig({
daemonServerUrl: cfg.daemon_server_url,

View File

@@ -25,6 +25,12 @@ export interface VCSConnection {
export interface ListVCSConnectionsResponse {
connections: VCSConnection[];
/** Whether this deployment offers the integration at all (self-host only;
* off on the managed cloud). When false the section is hidden entirely.
* Older backends omit it; treat as true so the existing self-host UI still
* renders (visibility is also gated by vcs_integration_available on
* /api/config, which is the authoritative deployment signal). */
available?: boolean;
/** Whether the deployment has MULTICA_VCS_SECRET_KEY configured. When false
* the connect form is disabled. Older backends omit it; treat as false. */
configured?: boolean;

View File

@@ -63,6 +63,9 @@ describe("Settings IntegrationsTab", () => {
queryCallsRef.current = [];
composioErrorRef.current = null;
configStore.getState().setFeatureFlags({ [COMPOSIO_MCP_APPS_FLAG]: true });
// Reset the self-host-only VCS gate to its default (hidden) so tests stay
// isolated; individual tests opt in below.
configStore.getState().setAuthConfig({ allowSignup: true, vcsIntegrationAvailable: false });
});
it("hides Composio and disables the toolkits query when the feature flag is off", () => {
@@ -89,4 +92,19 @@ describe("Settings IntegrationsTab", () => {
expect(screen.queryByTestId("composio-tab")).toBeNull();
});
it("hides the Git providers section when the deployment reports it unavailable", () => {
// Default (managed cloud / older server): vcsIntegrationAvailable is false.
renderTab();
expect(screen.queryByTestId("vcs-tab")).toBeNull();
});
it("shows the Git providers section on a self-hosted deployment that enables it", () => {
configStore.getState().setAuthConfig({ allowSignup: true, vcsIntegrationAvailable: true });
renderTab();
expect(screen.getByTestId("vcs-tab")).toBeInTheDocument();
});
});

View File

@@ -7,7 +7,7 @@ import { SlackTab } from "./slack-tab";
import { VCSTab } from "./vcs-tab";
import { ApiError } from "@multica/core/api";
import { composioToolkitsOptions } from "@multica/core/composio";
import { useFeatureEnabled } from "@multica/core/config";
import { useConfigStore, useFeatureEnabled } from "@multica/core/config";
import { COMPOSIO_MCP_APPS_FLAG } from "@multica/core/feature-flags";
import { useT } from "../../i18n";
import { SettingsSection, SettingsTab } from "./settings-layout";
@@ -33,6 +33,11 @@ export function IntegrationsTab() {
const composioUnconfigured =
composioToolkits.error instanceof ApiError && composioToolkits.error.status === 503;
// Self-host-only integration: the managed cloud reports this false (field
// omitted from /api/config), so the whole section — header included — is
// hidden there rather than showing an operator-only "missing key" message.
const vcsAvailable = useConfigStore((s) => s.vcsIntegrationAvailable);
return (
<SettingsTab title={t(($) => $.page.tabs.integrations)}>
<SettingsSection title={t(($) => $.lark.section_title)}>
@@ -46,9 +51,11 @@ export function IntegrationsTab() {
<SettingsSection title={t(($) => $.slack.section_title)}>
<SlackTab />
</SettingsSection>
<SettingsSection title={t(($) => $.vcs.section_title)}>
<VCSTab />
</SettingsSection>
{vcsAvailable && (
<SettingsSection title={t(($) => $.vcs.section_title)}>
<VCSTab />
</SettingsSection>
)}
</SettingsTab>
);
}

View File

@@ -204,6 +204,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
AllowedEmails: splitAndTrim(os.Getenv("ALLOWED_EMAILS")),
AllowedEmailDomains: splitAndTrim(os.Getenv("ALLOWED_EMAIL_DOMAINS")),
DisableWorkspaceCreation: os.Getenv("DISABLE_WORKSPACE_CREATION") == "true",
VCSIntegrationEnabled: os.Getenv("MULTICA_VCS_INTEGRATION_ENABLED") == "true",
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MULTICA_PUBLIC_URL")), "/"),
TrustedProxies: parseTrustedProxies(os.Getenv("MULTICA_TRUSTED_PROXIES")),
CloudRuntimeFleetURL: cloudRuntimeFleetURLFromEnv(),

View File

@@ -37,6 +37,15 @@ type AppConfig struct {
DaemonServerURL string `json:"daemon_server_url,omitempty"`
DaemonAppURL string `json:"daemon_app_url,omitempty"`
// VCSIntegrationAvailable mirrors the MULTICA_VCS_INTEGRATION_ENABLED
// deployment switch so the Settings UI can hide the whole self-hosted Git
// provider section on deployments where it is off (the managed cloud),
// instead of rendering it and surfacing an operator-only "missing
// MULTICA_VCS_SECRET_KEY" hint a cloud user cannot resolve. Omitted when
// false so the managed-cloud response keeps its previous shape; the UI
// defaults absent to false (hidden).
VCSIntegrationAvailable bool `json:"vcs_integration_available,omitempty"`
// PostHog public config for the frontend. The key is the same Project
// API Key the backend uses; returning it here (instead of baking it
// into the frontend bundle via NEXT_PUBLIC_*) means self-hosted
@@ -73,6 +82,7 @@ func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) {
}
config.CdnSigned = h.CFSigner != nil
config.DaemonServerURL, config.DaemonAppURL = daemonSetupURLsFromEnv()
config.VCSIntegrationAvailable = h.cfg.VCSIntegrationEnabled
config.FeatureFlags = featureflags.EvaluateFrontendPublicFlags(r.Context(), h.FeatureFlags)
// Only surface the build version on self-hosted deployments. The managed
// cloud is continuously deployed and its users can't choose the build, so

View File

@@ -66,6 +66,16 @@ type Config struct {
// invitation only. The public /api/config endpoint mirrors this flag so
// the UI can hide every "Create workspace" affordance — see #3433.
DisableWorkspaceCreation bool
// VCSIntegrationEnabled gates the self-hosted Git provider integration
// (Forgejo / Gitea / GitLab) at the deployment level, independent of whether
// MULTICA_VCS_SECRET_KEY is set. It is the product boundary: the feature is
// intended for self-hosted Multica only (where Multica and the Git instance
// can share a network), and is left off on the managed cloud — connect,
// rotate, and webhook handlers reject when it is false, and /api/config
// omits it so the UI hides the whole section rather than showing a
// "missing key" message a cloud user cannot act on. Populated from
// MULTICA_VCS_INTEGRATION_ENABLED; the self-host compose defaults it on.
VCSIntegrationEnabled bool
// PublicURL is the absolute base URL the API is reachable at from the
// public internet, with no trailing slash (e.g. "https://multica.ai").
// Used only to build webhook_url responses for autopilot webhook triggers

View File

@@ -44,6 +44,13 @@ type VCSConnectResponse struct {
const vcsWebhookPathPrefix = "/api/webhooks/vcs/"
// isVCSAvailable reports whether this deployment offers the self-hosted Git
// provider integration at all. It is the product boundary (self-host only) and
// is independent of isVCSConfigured (whether the encryption key is set): the
// managed cloud leaves it off, so connect/rotate/webhook reject and the UI
// hides the section rather than surfacing an operator-only "missing key" hint.
func (h *Handler) isVCSAvailable() bool { return h.cfg.VCSIntegrationEnabled }
func (h *Handler) isVCSConfigured() bool { return h.VCSSecretBox != nil }
func (h *Handler) vcsWebhookPath(connID string) string { return vcsWebhookPathPrefix + connID }
@@ -106,6 +113,20 @@ func (h *Handler) ListVCSConnections(w http.ResponseWriter, r *http.Request) {
member, _ := middleware.MemberFromContext(r.Context())
canManage := roleAllowed(member.Role, "owner", "admin")
// Deployments where the integration is off (the managed cloud) report
// available=false and nothing else, so the UI hides the whole section
// instead of showing an operator-only "missing key" hint. No connections
// can exist there anyway (connect is rejected), so skip the query.
if !h.isVCSAvailable() {
writeJSON(w, http.StatusOK, map[string]any{
"connections": []VCSConnectionResponse{},
"available": false,
"configured": false,
"can_manage": false,
})
return
}
rows, err := h.Queries.ListVCSConnectionsByWorkspace(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list connections")
@@ -117,6 +138,7 @@ func (h *Handler) ListVCSConnections(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]any{
"connections": out,
"available": true,
"configured": h.isVCSConfigured(),
"can_manage": canManage,
})
@@ -138,6 +160,10 @@ func (h *Handler) ConnectVCS(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if !h.isVCSAvailable() {
writeError(w, http.StatusNotFound, "vcs integration is not available on this deployment")
return
}
if !h.isVCSConfigured() {
writeError(w, http.StatusServiceUnavailable, "vcs integration not configured (MULTICA_VCS_SECRET_KEY unset)")
return
@@ -256,6 +282,10 @@ func (h *Handler) RotateVCSConnectionWebhook(w http.ResponseWriter, r *http.Requ
if !ok {
return
}
if !h.isVCSAvailable() {
writeError(w, http.StatusNotFound, "vcs integration is not available on this deployment")
return
}
if !h.isVCSConfigured() {
writeError(w, http.StatusServiceUnavailable, "vcs integration not configured (MULTICA_VCS_SECRET_KEY unset)")
return

View File

@@ -85,6 +85,13 @@ func vcsPullRequestRowToResponse(p db.ListVCSPullRequestsByIssueRow) GitHubPullR
// adapter handles the provider-specific signature scheme, event header, and
// payload shape, returning normalized events to the shared mirror logic below.
func (h *Handler) HandleVCSWebhook(w http.ResponseWriter, r *http.Request) {
// Where the integration is off (the managed cloud) the endpoint behaves as
// if it does not exist — a bare 404 that reveals nothing about config, the
// same response a genuinely unknown connection id gets below.
if !h.isVCSAvailable() {
writeError(w, http.StatusNotFound, "unknown connection")
return
}
if !h.isVCSConfigured() {
writeError(w, http.StatusServiceUnavailable, "vcs webhooks not configured")
return

View File

@@ -27,7 +27,14 @@ func withVCSBox(t *testing.T) *secretbox.Box {
}
prev := testHandler.VCSSecretBox
testHandler.VCSSecretBox = box
t.Cleanup(func() { testHandler.VCSSecretBox = prev })
// The feature also requires the deployment-level switch; the box alone is
// no longer sufficient. Enable it for the "configured" path tests.
prevEnabled := testHandler.cfg.VCSIntegrationEnabled
testHandler.cfg.VCSIntegrationEnabled = true
t.Cleanup(func() {
testHandler.VCSSecretBox = prev
testHandler.cfg.VCSIntegrationEnabled = prevEnabled
})
return box
}
@@ -504,6 +511,31 @@ func TestVCSWebhook_UnknownConnection(t *testing.T) {
}
}
// TestVCSWebhook_DisabledDeploymentReturns404 verifies the deployment-level
// switch is enforced server-side: with the integration off (the managed-cloud
// posture), even a valid, correctly-signed delivery to a real connection is
// rejected with a bare 404, so the feature is never processed and reveals
// nothing about config. The availability gate short-circuits before signature
// verification.
func TestVCSWebhook_DisabledDeploymentReturns404(t *testing.T) {
ctx := context.Background()
box := withVCSBox(t) // sets the box AND enables the switch
connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo.test")
t.Cleanup(func() { cleanupVCS(ctx, "") })
// Now flip the deployment switch off (withVCSBox's cleanup restores it).
testHandler.cfg.VCSIntegrationEnabled = false
raw := []byte(`{"action":"opened","pull_request":{"number":1}}`)
w := httptest.NewRecorder()
testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{
"X-Gitea-Event": "pull_request", "X-Gitea-Signature": giteaSig(raw),
}, raw))
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 when integration disabled, got %d (%s)", w.Code, w.Body.String())
}
}
func TestVCSWebhook_MalformedTolerated(t *testing.T) {
ctx := context.Background()
box := withVCSBox(t)