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..6cd35b0e --- /dev/null +++ b/.github/workflows/block-internal-paths.yml @@ -0,0 +1,107 @@ +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] + # Required for GitHub merge queue: the queue's pre-merge CI run on + # `gh-readonly-queue/...` refs needs this check to fire so the queue + # gets a real result instead of stalling forever AWAITING_CHECKS. + merge_group: + types: [checks_requested] + +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 + + # For pull_request events the diff base is github.event.pull_request.base.sha, + # which may be many commits behind HEAD and therefore absent from the + # shallow clone above. Fetch it explicitly (depth=1 keeps it fast). + - name: Fetch PR base SHA (pull_request events only) + if: github.event_name == 'pull_request' + run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }} + + - 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/.github/workflows/check-merge-group-trigger.yml b/.github/workflows/check-merge-group-trigger.yml new file mode 100644 index 00000000..77f4c7b3 --- /dev/null +++ b/.github/workflows/check-merge-group-trigger.yml @@ -0,0 +1,123 @@ +name: Check merge_group trigger on required workflows + +# Pre-merge guard against the deadlock pattern where a workflow whose +# check is in `required_status_checks` lacks a `merge_group:` trigger. +# Without it, GitHub merge queue stalls forever in AWAITING_CHECKS +# because the required check can't fire on `gh-readonly-queue/...` refs. +# +# This workflow: +# 1. Lists required status checks on the branch protection rule for `staging` +# 2. For each required check, finds the workflow that produces it (by job +# name match) +# 3. Fails if any such workflow lacks `merge_group:` in its triggers +# +# Reasoning for staging-only: main has its own CI gating model (PR review), +# but staging is what the merge queue runs on, so it's the trigger that +# matters. + +on: + pull_request: + paths: + - '.github/workflows/**.yml' + - '.github/workflows/**.yaml' + push: + branches: [staging, main] + paths: + - '.github/workflows/**.yml' + - '.github/workflows/**.yaml' + # Self-listen on merge_group so the linter passes its own queue run. + merge_group: + types: [checks_requested] + +jobs: + check: + name: Required workflows have merge_group trigger + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - name: Verify merge_group trigger on required-check workflows + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + shell: bash + run: | + set -euo pipefail + + # Branch we care about — the one merge queue runs on. + BRANCH=staging + + # Pull the list of required status check contexts. If the branch + # has no protection or no required checks, exit clean — nothing + # to lint. + REQUIRED=$(gh api "repos/${REPO}/branches/${BRANCH}/protection/required_status_checks" \ + --jq '.contexts[]' 2>/dev/null || true) + if [ -z "$REQUIRED" ]; then + echo "No required status checks on ${BRANCH} — nothing to verify." + exit 0 + fi + + echo "Required checks on ${BRANCH}:" + echo "${REQUIRED}" | sed 's/^/ - /' + echo + + # Build a map: workflow file -> set of job names declared in it. + # We use yq if available, otherwise grep the `name:` lines under + # `jobs:`. Stick with grep for portability — runner image always + # has it; yq isn't in the default image as of 2026-04. + declare -A workflow_jobs + shopt -s nullglob + for wf in .github/workflows/*.yml .github/workflows/*.yaml; do + [ -f "$wf" ] || continue + # Extract the workflow name (the `name:` at file root). + wf_name=$(awk '/^name:[[:space:]]/ {sub(/^name:[[:space:]]+/,""); gsub(/^"|"$/,""); print; exit}' "$wf") + # Extract job step names from the `jobs:` block. A job step is: + # - id under `jobs:` (key with 2-space indent followed by colon) + # - the `name:` field inside that job (4-space indent) + # We collect both because required_status_checks contexts can + # match either, depending on how the workflow was authored. + jobs_block=$(awk '/^jobs:/{flag=1; next} flag' "$wf") + job_names=$(echo "$jobs_block" | awk '/^[[:space:]]{4}name:[[:space:]]/ {sub(/^[[:space:]]+name:[[:space:]]+/,""); gsub(/^["'"'"']|["'"'"']$/,""); print}') + workflow_jobs["$wf"]="${wf_name}"$'\n'"${job_names}" + done + + # For each required check, find the workflow that produces it. + # Then verify that workflow lists merge_group as a trigger. + FAILED=0 + while IFS= read -r check; do + [ -z "$check" ] && continue + owning_wf="" + for wf in "${!workflow_jobs[@]}"; do + if echo "${workflow_jobs[$wf]}" | grep -Fxq "$check"; then + owning_wf="$wf" + break + fi + done + + if [ -z "$owning_wf" ]; then + echo "::warning::Required check '${check}' has no matching workflow in this repo. Skipping (may be from an external app)." + continue + fi + + # Does the workflow's trigger list include merge_group? + # Match either bare `merge_group:` line or merge_group with + # subsequent indented config (types: [checks_requested]). + if grep -qE '^[[:space:]]*merge_group:' "$owning_wf"; then + echo "OK: '${check}' (in $owning_wf) — has merge_group trigger" + else + echo "::error file=${owning_wf}::Required check '${check}' is produced by ${owning_wf}, but the workflow does not declare a 'merge_group:' trigger. With merge queue enabled on ${BRANCH}, this will deadlock the queue (every PR sits AWAITING_CHECKS forever). Add this to the workflow's 'on:' block:" + echo "::error file=${owning_wf}:: merge_group:" + echo "::error file=${owning_wf}:: types: [checks_requested]" + FAILED=1 + fi + done <<< "$REQUIRED" + + if [ "$FAILED" -ne 0 ]; then + echo + echo "::error::Block. See errors above. Reference: $(grep -l 'reference_merge_queue' /dev/null 2>/dev/null || echo 'memory: reference_merge_queue_enablement.md')." + exit 1 + fi + + echo + echo "All required workflows on ${BRANCH} declare merge_group triggers." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1f9cdbb..2ee5fe5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,9 +5,17 @@ on: branches: [main, staging] pull_request: branches: [main, staging] + # GitHub merge queue fires `merge_group` for the queue's pre-merge CI run. + # Required so the queue gets a real check result instead of a false-green + # from the absence of a triggered workflow. Safe to add unconditionally — + # the event simply doesn't fire until the queue is enabled on the branch. + merge_group: + types: [checks_requested] # Cancel in-progress CI runs when a new commit arrives on the same ref. -# This prevents stale runs from queuing behind each other. +# This prevents stale runs from queuing behind each other. The merge_group +# refs (refs/heads/gh-readonly-queue/...) get their own concurrency group +# automatically because github.ref differs from the PR ref. concurrency: group: ci-${{ github.ref }} cancel-in-progress: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e1661304..22d095b4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -18,6 +18,12 @@ on: branches: [main, staging] pull_request: branches: [main, staging] + # GitHub merge queue fires `merge_group` for the queue's pre-merge CI run. + # Required so CodeQL Analyze checks get a real result on the queued + # commit instead of a false-green. Event only fires once merge queue is + # enabled on the target branch — safe to add unconditionally. + merge_group: + types: [checks_requested] schedule: # Weekly run picks up findings in code that hasn't been touched. - cron: '30 1 * * 0' diff --git a/.github/workflows/e2e-api.yml b/.github/workflows/e2e-api.yml index 43f1004c..a0238dcd 100644 --- a/.github/workflows/e2e-api.yml +++ b/.github/workflows/e2e-api.yml @@ -1,35 +1,21 @@ name: E2E API Smoke Test # Extracted from ci.yml so workflow-level concurrency can protect this job # from run-level cancellation (issue #458). -# -# Problem: the job-level `concurrency.cancel-in-progress: false` in ci.yml -# prevented *sibling* E2E jobs from killing each other, but GitHub still -# cancelled the parent *workflow run* when a new push arrived. Since the job -# lived inside that run, it got cancelled too. -# -# Fix: a dedicated workflow gets its own concurrency group at the workflow -# level. New pushes to the same branch queue here instead of cancelling. -# Fast jobs (platform-build, canvas-build, etc.) stay in ci.yml and continue -# to benefit from run-level cancellation for quick feedback. on: push: - branches: [main] + branches: [main, staging] paths: - 'workspace-server/**' - 'tests/e2e/**' - '.github/workflows/e2e-api.yml' pull_request: - branches: [main] + branches: [main, staging] paths: - 'workspace-server/**' - 'tests/e2e/**' - '.github/workflows/e2e-api.yml' -# Workflow-level concurrency: new runs queue rather than cancel. -# `cancel-in-progress: false` is load-bearing — without it GitHub would still -# cancel this run when the next push arrives, defeating the whole fix. -# The group key includes github.ref so PRs don't compete with main. concurrency: group: e2e-api-${{ github.ref }} cancel-in-progress: false @@ -39,12 +25,6 @@ jobs: name: E2E API Smoke Test runs-on: ubuntu-latest timeout-minutes: 15 - # Postgres + Redis run as sibling containers via `docker run`. Could - # switch to a `services:` block now that we're on Linux, but the - # explicit start-and-wait gives us pg_isready / PING readiness checks - # that match the 30-tick timeouts the rest of the job expects. Ports - # 15432/16379 avoid collision with anything the host may already have - # on the standard ports. env: DATABASE_URL: postgres://dev:dev@localhost:15432/molecule?sslmode=disable REDIS_URL: redis://localhost:16379 @@ -61,12 +41,7 @@ jobs: - name: Start Postgres (docker) run: | docker rm -f "$PG_CONTAINER" 2>/dev/null || true - docker run -d --name "$PG_CONTAINER" \ - -e POSTGRES_USER=dev \ - -e POSTGRES_PASSWORD=dev \ - -e POSTGRES_DB=molecule \ - -p 15432:5432 \ - postgres:16 + docker run -d --name "$PG_CONTAINER" -e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=molecule -p 15432:5432 postgres:16 for i in $(seq 1 30); do if docker exec "$PG_CONTAINER" pg_isready -U dev >/dev/null 2>&1; then echo "Postgres ready after ${i}s" @@ -89,6 +64,7 @@ jobs: sleep 1 done echo "::error::Redis did not become ready in 15s" + docker logs "$REDIS_CONTAINER" || true exit 1 - name: Build platform working-directory: workspace-server @@ -111,16 +87,14 @@ jobs: cat workspace-server/platform.log || true exit 1 - name: Assert migrations applied - # Migrations auto-run at platform boot. Fail fast if they silently - # didn't — catches future migration-author mistakes before the E2E run. run: | tables=$(docker exec "$PG_CONTAINER" psql -U dev -d molecule -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='workspaces'") if [ "$tables" != "1" ]; then - echo "::error::Migrations did not apply — 'workspaces' table missing" + echo "::error::Migrations did not apply" cat workspace-server/platform.log || true exit 1 fi - echo "Migrations OK (workspaces table present)" + echo "Migrations OK" - name: Run E2E API tests run: bash tests/e2e/test_api.sh - name: Dump platform log on failure diff --git a/.github/workflows/e2e-staging-canvas.yml b/.github/workflows/e2e-staging-canvas.yml index c90794bd..dbdab154 100644 --- a/.github/workflows/e2e-staging-canvas.yml +++ b/.github/workflows/e2e-staging-canvas.yml @@ -5,18 +5,21 @@ name: E2E Staging Canvas (Playwright) # e2e-staging-saas.yml (which tests the API shape) by exercising the # actual browser + canvas bundle against live staging. # -# Triggers: push to main or PR touching canvas sources + this workflow, +# Triggers: push to main/staging or PR touching canvas sources + this workflow, # manual dispatch, and weekly cron to catch browser/runtime drift even # when canvas is quiet. +# Added staging to push/pull_request branches so the auto-promote gate +# check (--event push --branch staging) can see a completed run for this +# workflow — mirrors what PR #1891 does for e2e-api.yml. on: push: - branches: [main] + branches: [main, staging] paths: - 'canvas/**' - '.github/workflows/e2e-staging-canvas.yml' pull_request: - branches: [main] + branches: [main, staging] paths: - 'canvas/**' - '.github/workflows/e2e-staging-canvas.yml' diff --git a/.github/workflows/e2e-staging-saas.yml b/.github/workflows/e2e-staging-saas.yml index c1e2b878..8ef1c950 100644 --- a/.github/workflows/e2e-staging-saas.yml +++ b/.github/workflows/e2e-staging-saas.yml @@ -5,7 +5,7 @@ name: E2E Staging SaaS (full lifecycle) # HMA memory → activity → peers), then tears down and asserts leak-free. # # Why a separate workflow (not folded into ci.yml): -# - The run takes ~20 min (EC2 boot + cloudflared DNS + provision sweeps + +# - The run takes ~25-35 min (EC2 boot + cloudflared DNS + provision sweeps + # agent bootstrap), way too slow for every PR. # - Needs its own concurrency group so two pushes don't fight over the # same staging org slug prefix. @@ -68,7 +68,7 @@ jobs: e2e-staging-saas: name: E2E Staging SaaS runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 45 permissions: contents: read diff --git a/.github/workflows/publish-workspace-server-image.yml b/.github/workflows/publish-workspace-server-image.yml index df0c3098..c7f3127f 100644 --- a/.github/workflows/publish-workspace-server-image.yml +++ b/.github/workflows/publish-workspace-server-image.yml @@ -73,7 +73,20 @@ jobs: # - canary-verify.yml runs smoke tests against them # - On green → canary-verify retags :staging- → :latest # - On red → :latest stays on the prior good digest, prod is safe - - name: Build & push platform image to GHCR (staging- only) + # Every push of :staging- also retags the same digest as + # :staging-latest so staging CP (which pins TENANT_IMAGE at + # :staging-latest) picks up new builds automatically — no more manual + # Railway env-var edits. Prod's :latest retag still happens in + # canary-verify.yml after the canary fleet greenlights this digest; + # :staging-latest is strictly the "most recent main build," not a + # canary-verified promotion. + # + # Before this, TENANT_IMAGE on Railway staging was pinned to a static + # :staging- and drifted months behind (2026-04-24 incident: + # canary tenant ran :staging-a14cf86, 10 days stale, which lacked + # applyRuntimeModelEnv and caused every E2E to route hermes+openai + # through openrouter → 401). See issue filed with this PR. + - name: Build & push platform image to GHCR (staging- + staging-latest) uses: docker/build-push-action@v6 with: context: . @@ -82,6 +95,7 @@ jobs: push: true tags: | ${{ env.IMAGE_NAME }}:staging-${{ steps.tags.outputs.sha }} + ${{ env.IMAGE_NAME }}:staging-latest cache-from: type=gha cache-to: type=gha,mode=max labels: | @@ -89,7 +103,7 @@ jobs: org.opencontainers.image.revision=${{ github.sha }} org.opencontainers.image.description=Molecule AI platform (Go API server) — pending canary verify - - name: Build & push tenant image to GHCR (staging- only) + - name: Build & push tenant image to GHCR (staging- + staging-latest) uses: docker/build-push-action@v6 with: context: . @@ -98,6 +112,7 @@ jobs: push: true tags: | ${{ env.TENANT_IMAGE_NAME }}:staging-${{ steps.tags.outputs.sha }} + ${{ env.TENANT_IMAGE_NAME }}:staging-latest cache-from: type=gha cache-to: type=gha,mode=max # Canvas uses same-origin fetches. The tenant Go platform 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/e2e/staging-setup.ts b/canvas/e2e/staging-setup.ts index 598fb877..7147f4ea 100644 --- a/canvas/e2e/staging-setup.ts +++ b/canvas/e2e/staging-setup.ts @@ -26,8 +26,13 @@ const CP_URL = process.env.MOLECULE_CP_URL || "https://staging-api.moleculesai.a const ADMIN_TOKEN = process.env.MOLECULE_ADMIN_TOKEN; const STAGING = process.env.CANVAS_E2E_STAGING === "1"; -const PROVISION_TIMEOUT_MS = 15 * 60 * 1000; -const WORKSPACE_ONLINE_TIMEOUT_MS = 10 * 60 * 1000; +// Tenant cold boot on staging regularly takes 12-15 min when the +// workspace-server Docker image isn't already cached on the AMI. Raised +// to 20 min to match tests/e2e/test_staging_full_saas.sh (PR #1930) +// after repeated "tenant provision: timed out after 900s" flakes +// were blocking staging→main syncs on 2026-04-24. +const PROVISION_TIMEOUT_MS = 20 * 60 * 1000; +const WORKSPACE_ONLINE_TIMEOUT_MS = 20 * 60 * 1000; const TLS_TIMEOUT_MS = 3 * 60 * 1000; async function jsonFetch( diff --git a/canvas/src/app/blog/2026-04-20-chrome-devtools-mcp/page.mdx b/canvas/src/app/blog/2026-04-20-chrome-devtools-mcp/page.mdx new file mode 100644 index 00000000..f4ec240e --- /dev/null +++ b/canvas/src/app/blog/2026-04-20-chrome-devtools-mcp/page.mdx @@ -0,0 +1,240 @@ +--- +title: "Give Your AI Agent Browser Superpowers: Chrome DevTools MCP Integration" +date: "2026-04-20" +canonical: "https://docs.molecule.ai/blog/chrome-devtools-mcp" +og_title: "Give Your AI Agent Browser Superpowers with Chrome DevTools MCP" +og_description: "Chrome DevTools MCP brings AI agent browser control to Molecule AI. Every browser action is audit-attributed via org API keys. MCP browser automation with governance built in." +og_image: "/blog/chrome-devtools-mcp/chrome-devtools-mcp-social-card.png" +twitter_card: "summary_large_image" +author: "Molecule AI" +keywords: + - "AI agent browser control" + - "MCP browser automation" + - "browser automation AI agents" + - "browser automation governance" + - "Chrome DevTools MCP" + - "MCP governance layer" + - "AI agent web UI automation" +--- + +import { Callout } from '@/components/blog/Callout' +import { CodeBlock } from '@/components/blog/CodeBlock' + +# Give Your AI Agent Browser Superpowers: Chrome DevTools MCP Integration + +Every AI agent platform eventually gets asked the same question: "Can it interact with a web interface?" The answer is usually some variant of "sort of — give it your credentials and hope for the best." That's not a real answer. It's a trust fall. + +Chrome DevTools MCP changes this. It gives your AI agent a structured, governed interface to a real Chrome browser session — with full **MCP browser automation** capability and an audit trail that actually answers the question: "which agent touched what, and what did it do?" + +This post covers what Chrome DevTools MCP is, how Molecule AI's governance layer makes it enterprise-safe, and how to put it to work in your agent fleet. + +--- + +## What is Chrome DevTools MCP? + +Chrome DevTools MCP is an integration between the [MCP (Model Context Protocol)](https://modelcontextprotocol.io) and Google Chrome's DevTools Protocol. MCP is a standardized interface layer that lets AI agents connect to external tools with consistent tooling, authentication, and telemetry. The DevTools Protocol is Chrome's native debugging interface — the same interface your browser's developer tools use to inspect pages, capture network traffic, and control the browser. + +When you connect an AI agent to Chrome DevTools via MCP, you get: + +- **Full CDP access** — navigate, click, type, screenshot, evaluate JavaScript, read network logs, intercept requests, read cookies and local storage +- **MCP protocol layer** — structured JSON-RPC instead of raw CDP, consistent tool naming, type-safe parameters +- **Molecule AI governance layer** — org API key attribution, audit logging, session scoping, instant revocation + +The third item is what separates this from "use Puppeteer with an API key." It's the difference between browser automation AI agents and browser automation AI agents with a compliance story. + +--- + +## The Browser Problem: Trust Falls and Black Boxes + +When most teams give an AI agent browser access, the workflow looks like this: + +1. Agent receives a task ("find our competitors' pricing pages") +2. Agent uses browser credentials to log into Chrome +3. Agent navigates, reads, screenshots, and reports +4. Nobody knows exactly what the agent did, which session it used, or whether credentials were exposed + +This is a trust fall, not a governance model. The agent *can* do the task. But you have no audit trail if something goes wrong. No way to revoke access if the agent's behavior becomes unexpected. No attribution if you need to trace a call back to a specific integration. + +The **MCP governance layer** in Molecule AI addresses all three: + +- Every browser action is logged with the org API key prefix that initiated it +- Chrome sessions are token-scoped — Agent A's session is never Agent B's +- Revocation is one API call — the key stops working, the session closes, no redeploy required + +--- + +## How MCP Browser Automation Works in Molecule AI + +The integration uses Chrome's CDP over a WebSocket connection managed by the MCP server. Molecule AI's MCP server exposes a structured set of tools that map to CDP commands. Your agent calls these tools like any other MCP tool — the same interface whether you're automating Chrome, reading memory, or querying the platform API. + +Here's the sequence: + +1. **Workspace starts with a Chrome session attached** — the session is scoped to a specific Chrome profile or fresh browser context, isolated from other agents +2. **Agent calls MCP tools** — `cdp_navigate`, `cdp_click`, `cdp_evaluate`, `cdp_screenshot`, and others are available as structured tools with type-safe parameters +3. **Every call is audit-attributed** — the org API key prefix (e.g., `mole_a1b2`) is logged with the tool name, parameters, and result for every CDP call +4. **Session is revocable at any time** — revoke the org API key and the agent loses Chrome access immediately + +### AI Agent Browser Control: What You Can Do + +**Navigation and interaction:** +- `cdp_navigate` — navigate to any URL (supports `data:` and `about:` URLs via browser UI) +- `cdp_click` — click a DOM element by selector +- `cdp_type` — type text into a focused element +- `cdp_hover` — hover over a DOM element +- `cdp_scroll` — scroll an element or the page + +**Inspection and debugging:** +- `cdp_screenshot` — capture a full-page or viewport screenshot +- `cdp_evaluate` — execute JavaScript in the page context +- `cdp_get_cookies` / `cdp_set_cookies` — read and write cookies for authenticated sessions +- `cdp_get_local_storage` / `cdp_set_local_storage` — read and write localStorage + +**Network and performance:** +- `cdp_get_requests` — capture and filter network requests (XHR, fetch, WS) +- `cdp_block_urls` — block specific URL patterns to simulate adblocked environments +- `cdp_set_throttle` — throttle network conditions (3G, LTE, offline) + +--- + +## Browser Automation AI Agents: Use Cases That Actually Ship + +The Chrome DevTools MCP integration is most useful in workflows where browser state is the source of truth — and where audit attribution matters. + +### Automated Lighthouse audits on every PR + +A research agent runs a Lighthouse audit against every pull request in your repo. It navigates to the preview URL, captures the performance score, flags regressions below your threshold, and reports to the PM agent. Every audit run is logged with the org API key — your observability team can trace which agent ran which audit and when. + +```bash +# Agent calls cdp_navigate to the PR preview URL +# Agent calls cdp_evaluate to run Lighthouse inline +# Agent calls cdp_screenshot to capture the score +# Agent delegates results to PM workspace +``` + +### Visual regression detection + +An agent maintains a baseline set of screenshots for your key user flows. On every code change, it navigates to each flow, captures screenshots, and diffs against the baseline. Drift beyond your threshold opens a ticket automatically. The governance layer means your QA team can review the full history of which screenshots were captured, when, and by which agent. + +### Auth scraping + +An agent reads authenticated browser state from an existing Chrome session — cookies, localStorage, session tokens — and uses that state to authenticate API calls that would otherwise require separate credential management. The session is scoped; the credentials never leave the browser context. + +--- + +## MCP Governance Layer: Why It Matters + +The MCP protocol gives you tool connectivity. The governance layer is what makes it enterprise-ready. + +### Per-action audit logging + +Every CDP call your agent makes generates an audit log entry. The log includes: + +- **Org API key prefix** — which integration made the call (e.g., `mole_a1b2`) +- **Tool name and parameters** — `cdp_navigate(url=https://...)` +- **Result or error** — success, timeout, or CDP error code +- **Timestamp and workspace ID** — for timeline reconstruction + +This is the audit trail your security team will ask for in the next compliance review. It exists because Molecule AI's MCP server generates it — not because you built a custom logging pipeline. + +### Token-scoped Chrome sessions + +Chrome sessions are isolated per org API key. When you create an org API key for a specific integration (`lighthouse-reporter`), that key's Chrome session is separate from every other key's session. No credential cross-contamination — Agent A cannot read Agent B's authenticated state because their sessions are isolated at the MCP tool layer. + +### Instant revocation without redeployment + +If you need to revoke access — the integration is compromised, the agent behavior is unexpected, the contractor relationship ended — you revoke the org API key: + +```bash +curl -X DELETE https://platform.moleculesai.app/org/tokens/ \ + -H "Authorization: Bearer " +``` + +The key stops working immediately. The Chrome session is closed. The agent loses browser access before the next heartbeat. No redeploy, no container restart, no waiting for DNS cache expiration. + +--- + +## Setting Up Chrome DevTools MCP + +Chrome DevTools MCP requires a Chrome instance running with the remote debugging port enabled, and a `chromedp` or equivalent CDP client connected through Molecule AI's MCP server. + +### Step 1: Enable Chrome remote debugging + +Start Chrome with the `--remote-debugging-port=9222` flag: + +```bash +# macOS +/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ + --remote-debugging-port=9222 \ + --user-data-dir=/tmp/chrome-debug + +# Linux +google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug +``` + +### Step 2: Configure Molecule AI + +In your workspace config, add the Chrome DevTools MCP server URL: + +```yaml +# config.yaml +mcpServers: + - name: chrome-devtools + url: "http://localhost:9222" # CDP WebSocket endpoint + transport: cdp +``` + +### Step 3: Verify the connection + +Your agent can now call CDP tools. Test with a simple navigation: + +``` +Agent: navigate to https://example.com and screenshot the page +``` + +The audit log should show `cdp_navigate` and `cdp_screenshot` entries attributed to the workspace's org API key prefix. + +--- + +## What the Security Review Looks Like + +When your security team asks "what does this integration actually do?", here's the answer: + +**What it can do:** +- Navigate to any URL (with org API key attribution on every navigation) +- Read and write browser state (cookies, localStorage, session tokens) +- Screenshot pages and DOM elements +- Execute JavaScript in the page context + +**What it can't do (by default):** +- Access the host machine beyond the Chrome sandbox +- Read files outside the browser context +- Exfiltrate session tokens across session boundaries + +**What revocation looks like:** +- Revoke org API key → immediate session close +- No redeploy, no agent restart +- Audit trail shows every action taken before revocation + +--- + +## Browser Automation Governance: The Bigger Picture + +Chrome DevTools MCP is one piece of Molecule AI's broader MCP governance story. MCP is a general-purpose protocol — it connects agents to any tool that speaks CDP, stdio, or HTTP. The governance layer applies uniformly: every MCP call gets the same treatment — org API key attribution, audit logging, instant revocation. + +This means you can add new MCP integrations — databases, APIs, code execution environments — with the same governance posture. The MCP protocol is the connectivity layer. Molecule AI's MCP governance layer is the control plane. + +If you're evaluating AI agent platforms for browser automation governance, the question to ask is not "can it control a browser?" It's "can I audit every action, attribute every call, and revoke access in one step?" Chrome DevTools MCP with Molecule AI's MCP governance layer is the answer to that question. + +--- + +## Get Started + +Chrome DevTools MCP is available on all Molecule AI deployments running Phase 30 or later. + +- [MCP Server Setup Guide](/docs/guides/mcp-server-setup) — configure MCP tools in your workspace +- [Org API Keys: Audit Attribution Setup](/blog/org-scoped-api-keys) — set up org API keys with attribution +- [A2A Protocol Reference](/docs/api-protocol/a2a-protocol) — how agents delegate browser tasks to each other + + +Chrome DevTools MCP requires Chrome running with the remote debugging port enabled. CDP access is scoped per org API key — multiple agents can share Chrome sessions only if intentionally scoped that way via key design. + \ No newline at end of file diff --git a/canvas/src/components/AuditTrailPanel.tsx b/canvas/src/components/AuditTrailPanel.tsx index f7056dbe..b38b8fad 100644 --- a/canvas/src/components/AuditTrailPanel.tsx +++ b/canvas/src/components/AuditTrailPanel.tsx @@ -138,6 +138,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
{FILTERS.map((f) => (
)} )} + + )} + + {entry.error && ( +
{entry.error}
+ )} + + ))} + + + +
+
+ {onOpenSettings && ( + + )} +
+
+ + +
+
+ + + ); +} + +// ----------------------------------------------------------------------------- +// All-keys mode — every missingKey rendered as its own input, all required. +// ----------------------------------------------------------------------------- + +function AllKeysModal({ open, missingKeys, runtime, @@ -35,18 +376,23 @@ 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 +402,6 @@ export function MissingKeysModal({ setGlobalError(null); }, [open, missingKeys]); - // Focus first input when modal opens - useEffect(() => { - if (!open) return; - const raf = requestAnimationFrame(() => { - firstInputRef.current?.focus(); - }); - return () => cancelAnimationFrame(raf); - }, [open]); useEffect(() => { if (!open) return; const handler = (e: KeyboardEvent) => { @@ -90,7 +428,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, @@ -127,39 +464,45 @@ export function MissingKeysModal({ onKeysAdded(); }, [entries, onKeysAdded]); + // Focus trap: auto-focus first input when modal opens + useEffect(() => { + if (!open) return; + const timer = requestAnimationFrame(() => { + document.getElementById("missing-keys-title")?.focus(); + }); + return () => cancelAnimationFrame(timer); + }, [open]); + 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 */}