diff --git a/.env.example b/.env.example index 3888db48..32fac03a 100644 --- a/.env.example +++ b/.env.example @@ -34,7 +34,7 @@ PLUGINS_DIR= # Path to plugins/ directory (default: /plugins i # MOLECULE_MCP_ALLOW_SEND_MESSAGE= # Set to "true" to include send_message_to_user in the MCP bridge tool list (issue #810). Excluded by default to prevent unintended WebSocket pushes from CLI sessions. # MOLECULE_MCP_URL=http://localhost:8080 # Platform URL for opencode MCP config (opencode.json). Same as PLATFORM_URL; separate var so opencode configs can reference it without ambiguity. # WORKSPACE_DIR= # Optional global host path bind-mounted to /workspace in every container. Per-workspace workspace_dir column overrides this; if neither is set each workspace gets an isolated Docker named volume. -# MOLECULE_ENV=development # Environment label (development/staging/production). Used for log tagging and conditional behaviour. +MOLECULE_ENV=development # Environment label (development/staging/production). Used for log tagging and for the AdminAuth dev-mode escape hatch (lets the Canvas dashboard keep working after the first workspace is created, when ADMIN_TOKEN is unset). SaaS deployments MUST set MOLECULE_ENV=production. # MOLECULE_ENABLE_TEST_TOKENS= # Set to 1 to expose GET /admin/workspaces/:id/test-token (mints a fresh bearer token for E2E scripts). The route is auto-enabled when MOLECULE_ENV != production; this flag is the explicit override. Leave unset/0 in prod — the route 404s unless enabled. # MOLECULE_ORG_ID= # SaaS only: org UUID set by control plane on tenant machines. When set, workspace provisioning auto-routes through the control plane API instead of Docker. # CP_PROVISION_URL= # Override control plane URL for workspace provisioning (default: https://api.moleculesai.app). Only needed for testing against a non-production control plane. diff --git a/.github/workflows/block-internal-paths.yml b/.github/workflows/block-internal-paths.yml new file mode 100644 index 00000000..da4679b2 --- /dev/null +++ b/.github/workflows/block-internal-paths.yml @@ -0,0 +1,95 @@ +name: Block internal-flavored paths + +# Hard CI gate. Internal content (positioning, competitive briefs, sales +# playbooks, PMM/press drip, draft campaigns) lives in Molecule-AI/internal — +# this public monorepo must never re-acquire those paths. CEO directive +# 2026-04-23 after a fleet-wide audit found 79 internal files leaked here. +# +# Failure mode without this gate: agents (PMM, Research, DevRel, Sales) drop +# briefs into the easiest path their cwd resolves to (root /research, +# /marketing, /docs/marketing) and gitignore alone won't catch a `git add -f` +# or a stale gitignore line. This workflow is the mechanical backstop. + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [main, staging] + +jobs: + check: + name: Block forbidden paths + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 # need previous commit to diff against on push events + + - name: Refuse if forbidden paths appear + run: | + # Paths that must NEVER live in the public monorepo. Add to this + # list narrowly — broader patterns belong in .gitignore so day-to-day + # docs work isn't accidentally blocked. + FORBIDDEN_PATTERNS=( + "^research/" + "^marketing/" + "^docs/marketing/" + "^comment-[0-9]+\.json$" + "^test-pmm.*\.(txt|md)$" + "^tick-reflections.*\.(txt|md)$" + ".*-temp\.(md|txt)$" + ) + + # Determine the diff base. + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + else + BASE="${{ github.event.before }}" + HEAD="${{ github.event.after }}" + fi + + # Files added or modified in this change. + if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then + # New branch / no previous SHA — check entire tree. + CHANGED=$(git ls-tree -r --name-only HEAD) + else + CHANGED=$(git diff --name-only --diff-filter=AM "$BASE" "$HEAD") + fi + + if [ -z "$CHANGED" ]; then + echo "No changed files to inspect." + exit 0 + fi + + OFFENDING="" + for path in $CHANGED; do + for pattern in "${FORBIDDEN_PATTERNS[@]}"; do + if echo "$path" | grep -qE "$pattern"; then + OFFENDING="${OFFENDING}${path} (matched: ${pattern})\n" + break + fi + done + done + + if [ -n "$OFFENDING" ]; then + echo "::error::Forbidden internal-flavored paths detected:" + printf "$OFFENDING" + echo "" + echo "These paths belong in Molecule-AI/internal, not this public repo." + echo "See docs/internal-content-policy.md for canonical locations." + echo "" + echo "If your file is genuinely public-facing (e.g. a blog post" + echo "ready to ship), use one of these alternatives instead:" + echo " • Public-bound blog posts: docs/blog/.md" + echo " • Public-bound tutorials: docs/tutorials/.md" + echo " • Public devrel content: docs/devrel/.md" + echo "" + echo "If you legitimately need to add a new top-level path that" + echo "happens to match a forbidden pattern, edit" + echo ".github/workflows/block-internal-paths.yml and update the" + echo "FORBIDDEN_PATTERNS list with reviewer signoff." + exit 1 + fi + + echo "✓ No forbidden paths in this change." diff --git a/.gitignore b/.gitignore index 23d11e41..05da25ee 100644 --- a/.gitignore +++ b/.gitignore @@ -120,9 +120,29 @@ backups/ # org-templates live in Molecule-AI/molecule-ai-org-template-* repos # (including molecule-dev — no checkin exception). # plugins live in Molecule-AI/molecule-ai-plugin-* repos. +# All three directories are populated by scripts/clone-manifest.sh +# (now auto-run by infra/scripts/setup.sh). The in-tree exception for +# molecule-dev was removed because the checked-in copy drifted from +# the standalone repo and shipped with broken !include references to +# role files that never existed in the snapshot. /org-templates/ /plugins/ /workspace-configs-templates/ # Cloned by publish-workspace-server-image.yml so the Dockerfile's # replace-directive path resolves. Lives in its own repo. /molecule-ai-plugin-github-app-auth/ + +# Internal-flavored content lives in Molecule-AI/internal — NEVER in this +# public monorepo. Migrated 2026-04-23 (CEO directive). The CI workflow +# .github/workflows/block-internal-paths.yml enforces this; this gitignore +# is the second line of defence so accidental local writes don't reach a +# commit. See docs/internal-content-policy.md for the full rationale. +/research/ +/marketing/ +/docs/marketing/ +# Common temp/scratch patterns agents have produced +/comment-*.json +*-temp.md +*-temp.txt +/test-pmm-*.txt +/tick-reflections-*.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7cf4d45..8eaea59e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,11 @@ development workflow, conventions, and how to get your changes merged. - **Python 3.11+** — workspace runtime - **Docker** — infrastructure services (Postgres, Redis) - **Git** — with hooks path set to `.githooks` +- **jq** — parses `manifest.json` during `setup.sh` to clone the + template/plugin registry. Install via `brew install jq` (macOS) or + `apt install jq` (Debian). Without it, setup.sh prints a note and + leaves the registry dirs empty (recoverable by installing jq and + re-running). ### Setup diff --git a/README.md b/README.md index a845b6d0..3e3e0fb4 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,12 @@ cp .env.example .env # and Temporal (:7233 gRPC, :8233 UI) on the shared # `molecule-monorepo-net` Docker network. Temporal runs with # no auth on localhost — dev-only; production must gate it. +# +# Also populates the template/plugin registry by cloning every repo +# listed in manifest.json into workspace-configs-templates/, +# org-templates/, and plugins/. Requires jq — install via +# `brew install jq` (macOS) or `apt install jq` (Debian). Idempotent: +# re-runs skip any target dir that's already populated. cd workspace-server go run ./cmd/server # applies pending migrations on first boot diff --git a/README.zh-CN.md b/README.zh-CN.md index 7538c5c9..20df5685 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -260,6 +260,11 @@ cp .env.example .env # 以及 Temporal (:7233 gRPC, :8233 UI),全部挂在共享的 # `molecule-monorepo-net` Docker 网络上。Temporal 默认无鉴权, # 仅用于本地开发;生产环境必须加 mTLS / API Key。 +# +# 同时会根据 manifest.json 拉取所有模板/插件仓库到 +# workspace-configs-templates/、org-templates/、plugins/ 三个目录。 +# 需要安装 jq:`brew install jq`(macOS)或 `apt install jq`(Debian)。 +# 脚本幂等:已经存在内容的目录会被跳过,可以安全重跑。 cd workspace-server go run ./cmd/server # 首次启动会自动跑 schema_migrations 里未应用的迁移 diff --git a/canvas/src/components/CookieConsent.tsx b/canvas/src/components/CookieConsent.tsx index 5ea0dc57..2f04df39 100644 --- a/canvas/src/components/CookieConsent.tsx +++ b/canvas/src/components/CookieConsent.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { isSaaSTenant } from "@/lib/tenant"; const STORAGE_KEY = "molecule_cookie_consent"; @@ -74,7 +75,18 @@ export function CookieConsent() { // Read persisted decision on mount. useState's initialState can't run // on first render because localStorage is SSR-unsafe — defer to // useEffect so the initial HTML is identical to the server snapshot. + // + // The banner is SaaS-only: it carries a link to the hosted + // privacy policy (moleculesai.app/legal/privacy) and presumes + // GDPR/ePrivacy obligations that only apply to the hosted offering. + // Self-hosted / local-dev / Vercel-preview hosts get no banner — + // matches the `isSaaSTenant()` convention used by AuthGate and + // the tier picker. useEffect(() => { + if (!isSaaSTenant()) { + setVisible(false); + return; + } setVisible(getStoredConsent() === null); }, []); diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index 6318d0ae..344f0e46 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -89,7 +89,13 @@ export function CreateWorkspaceButton() { ], [isSaaS], ); - const defaultTier = isSaaS ? 4 : 1; + // T3 ("Privileged") is the self-hosted default — gives agents the + // read_write workspace mount + Docker daemon access most templates + // expect to do real work. T1 sandboxed and T2 standard are kept as + // explicit opt-ins for low-trust agents. SaaS still defaults to T4 + // because every SaaS workspace gets its own EC2 (sibling VMs, no + // shared blast radius — see isSaaSTenant() / tier picker hide logic). + const defaultTier = isSaaS ? 4 : 3; const [tier, setTier] = useState(defaultTier); // Refs for roving tabIndex on the tier radio group (WCAG 2.1 arrow-key nav) diff --git a/canvas/src/components/Legend.tsx b/canvas/src/components/Legend.tsx index ad7ec8fa..10964fd3 100644 --- a/canvas/src/components/Legend.tsx +++ b/canvas/src/components/Legend.tsx @@ -1,12 +1,18 @@ "use client"; import { STATUS_CONFIG } from "@/lib/design-tokens"; +import { useCanvasStore } from "@/store/canvas"; const LEGEND_STATUSES = ["online", "provisioning", "degraded", "failed", "paused", "offline"] as const; export function Legend() { + // TemplatePalette (when open) is fixed top-0 left-0 w-[280px] — the + // default bottom-6 left-4 position of this legend would sit under it. + // Shift past the 280 px palette + a 16 px gap when the palette is open. + const paletteOpen = useCanvasStore((s) => s.templatePaletteOpen); + const leftClass = paletteOpen ? "left-[296px]" : "left-4"; return ( -
+
Legend
{/* Status */} diff --git a/canvas/src/components/MissingKeysModal.tsx b/canvas/src/components/MissingKeysModal.tsx index 91346776..2c2a648e 100644 --- a/canvas/src/components/MissingKeysModal.tsx +++ b/canvas/src/components/MissingKeysModal.tsx @@ -1,33 +1,374 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { api } from "@/lib/api"; -import { getKeyLabel } from "@/lib/deploy-preflight"; +import { getKeyLabel, type ProviderChoice } from "@/lib/deploy-preflight"; interface Props { open: boolean; + /** Flat list of every candidate env var. Used as the fallback input + * set when `providers` is empty (or length 1). */ missingKeys: string[]; + /** Grouped provider options derived from the template's models[] / + * required_env. When length ≥ 2 the modal shows a radio picker. */ + providers?: ProviderChoice[]; + /** Runtime slug — used only for the "The runtime …" + * headline; behavior is driven by providers/missingKeys. */ runtime: string; - /** Called when user adds all keys and wants to proceed with deploy. */ + /** Called when all required keys for the chosen provider are saved. */ onKeysAdded: () => void; - /** Called when user cancels the deploy. */ + /** Called when the user cancels the deploy. */ onCancel: () => void; - /** Called when user wants to open the Settings Panel (Config tab → Secrets). */ + /** Optional — open the Settings Panel (Config tab → Secrets). */ onOpenSettings?: () => void; - /** Optional workspace ID — if provided, secrets are saved at workspace scope. */ + /** If provided, secrets save at workspace scope instead of global. */ workspaceId?: string; } interface KeyEntry { key: string; - label: string; value: string; saved: boolean; saving: boolean; error: string | null; } +/** + * MissingKeysModal + * ---------------- + * Dispatches between two modes based on what the template declares: + * + * 1. PROVIDER PICKER — when the preflight returned ≥2 `providers` (e.g. + * a Hermes template whose models[].required_env enumerate OpenRouter, + * Anthropic, Nous-native, etc.). Radio list of options, saving the + * chosen option's env vars satisfies the deploy. + * + * 2. ALL-KEYS — every entry in `missingKeys` rendered as its own input, + * all must save before Deploy. Used when the template has a single + * provider option or no declared alternatives. + * + * The modal never hardcodes per-runtime provider lists; the upstream + * preflight derives that from the template config.yaml. + */ export function MissingKeysModal({ + open, + missingKeys, + providers, + runtime, + onKeysAdded, + onCancel, + onOpenSettings, + workspaceId, +}: Props) { + const pickerProviders = providers ?? []; + const pickerMode = pickerProviders.length > 1; + + if (pickerMode) { + return ( + + ); + } + + // Prefer the (single) provider's envVars over the raw missingKeys when + // we have one — the provider list is already de-duped and ordered. + const keys = + pickerProviders.length === 1 ? pickerProviders[0].envVars : missingKeys; + + return ( + + ); +} + +// ----------------------------------------------------------------------------- +// Provider-picker mode — choose one option, save its env var(s), deploy. +// ----------------------------------------------------------------------------- + +function ProviderPickerModal({ + open, + providers, + runtime, + onKeysAdded, + onCancel, + onOpenSettings, + workspaceId, +}: { + open: boolean; + providers: ProviderChoice[]; + runtime: string; + onKeysAdded: () => void; + onCancel: () => void; + onOpenSettings?: () => void; + workspaceId?: string; +}) { + const [selectedId, setSelectedId] = useState(providers[0].id); + const [entries, setEntries] = useState([]); + const firstInputRef = useRef(null); + + const selected = useMemo( + () => providers.find((p) => p.id === selectedId) ?? providers[0], + [providers, selectedId], + ); + + useEffect(() => { + if (!open) return; + setSelectedId(providers[0].id); + }, [open, providers]); + + useEffect(() => { + if (!open) return; + setEntries( + selected.envVars.map((key) => ({ + key, + value: "", + saved: false, + saving: false, + error: null, + })), + ); + }, [open, selected]); + + useEffect(() => { + if (!open) return; + const raf = requestAnimationFrame(() => firstInputRef.current?.focus()); + return () => cancelAnimationFrame(raf); + }, [open, selectedId]); + + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onCancel(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onCancel]); + + const updateEntry = useCallback( + (index: number, updates: Partial) => { + setEntries((prev) => + prev.map((e, i) => (i === index ? { ...e, ...updates } : e)), + ); + }, + [], + ); + + const handleSaveKey = useCallback( + async (index: number) => { + const entry = entries[index]; + if (!entry.value.trim()) return; + updateEntry(index, { saving: true, error: null }); + try { + if (workspaceId) { + await api.put(`/workspaces/${workspaceId}/secrets`, { + key: entry.key, + value: entry.value.trim(), + }); + } else { + await api.put("/settings/secrets", { + key: entry.key, + value: entry.value.trim(), + }); + } + updateEntry(index, { saved: true, saving: false }); + } catch (e) { + updateEntry(index, { + saving: false, + error: e instanceof Error ? e.message : "Failed to save", + }); + } + }, + [entries, updateEntry, workspaceId], + ); + + if (!open) return null; + + const allSaved = entries.length > 0 && entries.every((e) => e.saved); + const anySaving = entries.some((e) => e.saving); + const runtimeLabel = runtime + .replace(/[-_]/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); + + return ( +
+ + ); +} + +// ----------------------------------------------------------------------------- +// All-keys mode — every missingKey rendered as its own input, all required. +// ----------------------------------------------------------------------------- + +function AllKeysModal({ open, missingKeys, runtime, @@ -35,18 +376,24 @@ export function MissingKeysModal({ onCancel, onOpenSettings, workspaceId, -}: Props) { +}: { + open: boolean; + missingKeys: string[]; + runtime: string; + onKeysAdded: () => void; + onCancel: () => void; + onOpenSettings?: () => void; + workspaceId?: string; +}) { const [entries, setEntries] = useState([]); const [globalError, setGlobalError] = useState(null); const firstInputRef = useRef(null); - // Initialize entries when modal opens or missingKeys change useEffect(() => { if (!open) return; setEntries( missingKeys.map((key) => ({ key, - label: getKeyLabel(key), value: "", saved: false, saving: false, @@ -56,14 +403,12 @@ export function MissingKeysModal({ setGlobalError(null); }, [open, missingKeys]); - // Focus first input when modal opens useEffect(() => { if (!open) return; - const raf = requestAnimationFrame(() => { - firstInputRef.current?.focus(); - }); + const raf = requestAnimationFrame(() => firstInputRef.current?.focus()); return () => cancelAnimationFrame(raf); }, [open]); + useEffect(() => { if (!open) return; const handler = (e: KeyboardEvent) => { @@ -90,7 +435,6 @@ export function MissingKeysModal({ updateEntry(index, { saving: true, error: null }); try { - // Save to global scope by default (available to all workspaces) if (workspaceId) { await api.put(`/workspaces/${workspaceId}/secrets`, { key: entry.key, @@ -129,37 +473,34 @@ export function MissingKeysModal({ if (!open) return null; - const allSaved = entries.every((e) => e.saved); + const allSaved = entries.length > 0 && entries.every((e) => e.saved); const anySaving = entries.some((e) => e.saving); - const runtimeLabel = runtime.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + const runtimeLabel = runtime + .replace(/[-_]/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); return (
- {/* Backdrop */}