forked from molecule-ai/molecule-core
merge(staging): resolve conflicts + fix 7 test regressions on top of #2061
- Merge origin/staging into fix/canvas-multilevel-layout-ux. 18 files
auto-merged (mostly canvas/tabs/chat and workspace-server handlers
the earlier DIRTY marker was stale relative to current staging).
- Fix 7 test failures surfaced by the merge:
1. Canvas.pan-to-node.test.tsx — mockGetIntersectingNodes was
inferred as vi.fn(() => never[]); mockReturnValueOnce of a node
object failed type check. Explicit return-type annotation.
2. Canvas.pan-to-node.test.tsx + Canvas.a11y.test.tsx — Canvas.tsx
reads deletingIds.size (new multilevel-layout state). Both mock
stores lacked deletingIds; added new Set<string>() to each.
3. canvas-batch-partial-failure.test.ts — makeWS() built a wire-
format WorkspaceData (snake_case, with x/y/uptime_seconds). The
store's node.data is now WorkspaceNodeData (camelCase, no wire-
only fields). Rewrote makeWS to produce WorkspaceNodeData and
updated 5 call-site casts. No assertions changed.
4. ConfigTab.hermes.test.tsx — two tests pinned pre-#2061 behavior
that the PR intentionally inverts:
a. "shows hermes-specific info banner" — RUNTIMES_WITH_OWN_CONFIG
now contains only {"external"}, so the banner is no longer
shown for hermes. Inverted assertion: now pins ABSENCE of
the banner, with a comment noting the inversion.
b. "config.yaml runtime wins over DB" — priority reversed:
DB is now authoritative so the tier-on-node badge matches
the form. Inverted scenario: DB=hermes + yaml=crewai →
form shows hermes. Switched test's DB runtime off langgraph
because the dropdown collapses langgraph into an empty-
valued "default" option that would hide the win signal.
- No production code changed — this commit is staging merge + test
realignment only. 953/953 canvas tests pass. tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
425df5e5a9
12
.github/workflows/block-internal-paths.yml
vendored
12
.github/workflows/block-internal-paths.yml
vendored
@ -15,6 +15,11 @@ on:
|
||||
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:
|
||||
@ -25,6 +30,13 @@ jobs:
|
||||
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
|
||||
|
||||
123
.github/workflows/check-merge-group-trigger.yml
vendored
Normal file
123
.github/workflows/check-merge-group-trigger.yml
vendored
Normal file
@ -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."
|
||||
9
.github/workflows/e2e-staging-canvas.yml
vendored
9
.github/workflows/e2e-staging-canvas.yml
vendored
@ -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'
|
||||
|
||||
4
.github/workflows/e2e-staging-saas.yml
vendored
4
.github/workflows/e2e-staging-saas.yml
vendored
@ -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
|
||||
|
||||
|
||||
@ -73,7 +73,20 @@ jobs:
|
||||
# - canary-verify.yml runs smoke tests against them
|
||||
# - On green → canary-verify retags :staging-<sha> → :latest
|
||||
# - On red → :latest stays on the prior good digest, prod is safe
|
||||
- name: Build & push platform image to GHCR (staging-<sha> only)
|
||||
# Every push of :staging-<sha> 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-<sha> 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-<sha> + 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-<sha> only)
|
||||
- name: Build & push tenant image to GHCR (staging-<sha> + 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
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS builder
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
@ -11,7 +11,7 @@ ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
|
||||
ENV NEXT_PUBLIC_ADMIN_TOKEN=$NEXT_PUBLIC_ADMIN_TOKEN
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
@ -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(
|
||||
|
||||
240
canvas/src/app/blog/2026-04-20-chrome-devtools-mcp/page.mdx
Normal file
240
canvas/src/app/blog/2026-04-20-chrome-devtools-mcp/page.mdx
Normal file
@ -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/<token-id> \
|
||||
-H "Authorization: Bearer <admin-session-token>"
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
<Callout variant="info">
|
||||
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.
|
||||
</Callout>
|
||||
@ -138,6 +138,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
<div className="px-4 py-2.5 border-b border-zinc-800/40 flex items-center gap-1 overflow-x-auto shrink-0">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
type="button"
|
||||
key={f.id}
|
||||
onClick={() => setFilter(f.id)}
|
||||
aria-pressed={filter === f.id}
|
||||
@ -152,6 +153,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadEntries}
|
||||
className="px-2 py-1 text-[10px] bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded transition-colors shrink-0"
|
||||
aria-label="Refresh audit trail"
|
||||
@ -190,6 +192,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
{cursor && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
className="px-4 py-2 text-[11px] bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed text-zinc-300 rounded-lg transition-colors"
|
||||
|
||||
@ -91,6 +91,7 @@ export function BatchActionBar() {
|
||||
|
||||
{/* Action buttons */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setPending("restart")}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-sky-300 bg-sky-900/30 hover:bg-sky-800/50 border border-sky-700/30 hover:border-sky-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/70"
|
||||
@ -100,6 +101,7 @@ export function BatchActionBar() {
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setPending("pause")}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-amber-300 bg-amber-900/30 hover:bg-amber-800/50 border border-amber-700/30 hover:border-amber-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/70"
|
||||
@ -109,6 +111,7 @@ export function BatchActionBar() {
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setPending("delete")}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-red-300 bg-red-900/30 hover:bg-red-800/50 border border-red-700/30 hover:border-red-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/70"
|
||||
@ -121,6 +124,7 @@ export function BatchActionBar() {
|
||||
|
||||
{/* Deselect */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={clearSelection}
|
||||
aria-label="Clear selection"
|
||||
|
||||
@ -108,6 +108,7 @@ export function BundleDropZone() {
|
||||
{/* Keyboard-accessible import button — visible on focus or hover so
|
||||
keyboard / AT users can trigger bundle import without drag-and-drop (WCAG 2.1.1) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
aria-label="Import bundle file"
|
||||
aria-controls="bundle-file-input"
|
||||
|
||||
@ -99,6 +99,7 @@ export function CommunicationOverlay() {
|
||||
if (!visible || comms.length === 0) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible(true)}
|
||||
aria-label="Show communications panel"
|
||||
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-zinc-900/90 border border-zinc-700/50 rounded-lg text-[10px] text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
@ -115,6 +116,7 @@ export function CommunicationOverlay() {
|
||||
<span aria-hidden="true">↗↙ </span>Communications ({comms.length})
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible(false)}
|
||||
aria-label="Close communications panel"
|
||||
className="text-zinc-500 hover:text-zinc-300 text-xs"
|
||||
|
||||
@ -121,6 +121,7 @@ export function ConfirmDialog({
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-zinc-800 bg-zinc-950/50">
|
||||
{!singleButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-3.5 py-1.5 text-[13px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
@ -128,6 +129,7 @@ export function ConfirmDialog({
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors ${confirmColors}`}
|
||||
>
|
||||
|
||||
@ -109,6 +109,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
ref={closeButtonRef}
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
@ -146,6 +147,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-zinc-800 bg-zinc-900/40">
|
||||
{output && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(output);
|
||||
@ -159,6 +161,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-[11px] text-zinc-300 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
|
||||
@ -308,6 +308,7 @@ export function ContextMenu() {
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
role="menuitem"
|
||||
onClick={item.action}
|
||||
|
||||
@ -112,6 +112,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
</div>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close conversation trace"
|
||||
className="text-zinc-500 hover:text-zinc-300 text-lg px-2"
|
||||
>
|
||||
@ -283,6 +284,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex justify-end">
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-1.5 text-[12px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg transition-colors"
|
||||
>
|
||||
Close
|
||||
|
||||
@ -211,7 +211,7 @@ export function CreateWorkspaceButton() {
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button className="fixed bottom-6 right-6 z-40 px-5 py-2.5 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm font-medium rounded-xl text-white shadow-lg shadow-blue-600/20 hover:shadow-xl hover:shadow-blue-500/30 transition-all duration-200 flex items-center gap-2">
|
||||
<button type="button" className="fixed bottom-6 right-6 z-40 px-5 py-2.5 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm font-medium rounded-xl text-white shadow-lg shadow-blue-600/20 hover:shadow-xl hover:shadow-blue-500/30 transition-all duration-200 flex items-center gap-2">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
@ -284,6 +284,7 @@ export function CreateWorkspaceButton() {
|
||||
</div>
|
||||
{TIERS.map((t, idx) => (
|
||||
<button
|
||||
type="button"
|
||||
key={t.value}
|
||||
ref={(el) => { radioRefs.current[idx] = el; }}
|
||||
role="radio"
|
||||
@ -432,11 +433,12 @@ export function CreateWorkspaceButton() {
|
||||
|
||||
<div className="flex justify-end gap-2.5 mt-6">
|
||||
<Dialog.Close asChild>
|
||||
<button className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-sm rounded-lg text-zinc-300 transition-colors">
|
||||
<button type="button" className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-sm rounded-lg text-zinc-300 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
className="px-5 py-2 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm rounded-lg text-white disabled:opacity-50 transition-colors"
|
||||
|
||||
@ -143,12 +143,14 @@ export function DeleteCascadeConfirmDialog({
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-zinc-800 bg-zinc-950/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-3.5 py-1.5 text-[13px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={!checked}
|
||||
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors
|
||||
|
||||
@ -110,6 +110,7 @@ export function EmptyState() {
|
||||
const tierColor = TIER_CONFIG[t.tier]?.border || TIER_CONFIG[1].border;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={t.id}
|
||||
onClick={() => deploy(t)}
|
||||
disabled={!!deploying}
|
||||
@ -140,6 +141,7 @@ export function EmptyState() {
|
||||
|
||||
{/* Create blank */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={createBlank}
|
||||
disabled={!!deploying}
|
||||
className="w-full rounded-xl border border-dashed border-zinc-700/60 bg-zinc-900/30 px-4 py-3 text-sm text-zinc-400 hover:text-zinc-200 hover:border-zinc-600 hover:bg-zinc-900/50 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-zinc-400 disabled:hover:border-zinc-700/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||
|
||||
@ -63,6 +63,7 @@ export class ErrorBoundary extends React.Component<
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
@ -80,6 +81,7 @@ export class ErrorBoundary extends React.Component<
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.handleReload}
|
||||
className="rounded-lg bg-blue-600 hover:bg-blue-500 px-5 py-2 text-sm font-medium text-white transition-colors"
|
||||
>
|
||||
|
||||
@ -160,6 +160,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
<div className="flex items-center gap-1">
|
||||
{SCOPES.map((scope) => (
|
||||
<button
|
||||
type="button"
|
||||
key={scope}
|
||||
onClick={() => setActiveScope(scope)}
|
||||
aria-pressed={activeScope === scope}
|
||||
@ -201,6 +202,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setDebouncedQuery("");
|
||||
@ -240,6 +242,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
: `${entries.length} memories`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadEntries}
|
||||
className="px-2 py-1 text-[11px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors"
|
||||
aria-label="Refresh memories"
|
||||
@ -273,6 +276,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
<p className="text-[11px] text-zinc-600 max-w-[200px] leading-relaxed">
|
||||
Try a different query or{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setDebouncedQuery("");
|
||||
@ -339,6 +343,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
||||
<div className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 overflow-hidden">
|
||||
{/* Header row */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-zinc-800/30 transition-colors"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
aria-expanded={expanded}
|
||||
@ -409,6 +414,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
||||
Created: {new Date(entry.created_at).toLocaleString()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
|
||||
@ -556,6 +556,7 @@ function AllKeysModal({
|
||||
className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-2 py-1.5 text-[11px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveKey(index)}
|
||||
disabled={!entry.value.trim() || entry.saving}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
|
||||
@ -580,6 +581,7 @@ function AllKeysModal({
|
||||
<div>
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
className="text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
@ -589,12 +591,14 @@ function AllKeysModal({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-3.5 py-1.5 text-[12px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel Deploy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddKeysAndDeploy}
|
||||
disabled={!allSaved || anySaving}
|
||||
className="px-3.5 py-1.5 text-[12px] bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors disabled:opacity-40"
|
||||
|
||||
@ -159,6 +159,7 @@ export function OnboardingWizard() {
|
||||
Step {currentStepIdx + 1} of {STEPS.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
aria-label="Skip onboarding guide"
|
||||
className="text-[10px] text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
@ -178,6 +179,7 @@ export function OnboardingWizard() {
|
||||
{/* Action button */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAction}
|
||||
className="flex-1 px-3 py-1.5 bg-blue-600/90 hover:bg-blue-500 rounded-lg text-[11px] font-medium text-white transition-colors"
|
||||
>
|
||||
@ -191,6 +193,7 @@ export function OnboardingWizard() {
|
||||
</button>
|
||||
{step !== "done" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = STEPS[currentStepIdx + 1];
|
||||
if (next) setStep(next.id);
|
||||
|
||||
@ -6,10 +6,16 @@ import { api } from "@/lib/api";
|
||||
import { showToast } from "./Toaster";
|
||||
import { ConsoleModal } from "./ConsoleModal";
|
||||
|
||||
/** Base provisioning timeout in milliseconds (2 minutes). Used as the
|
||||
* floor; the effective threshold scales with the number of workspaces
|
||||
* concurrently provisioning (see effectiveTimeoutMs below). */
|
||||
export const DEFAULT_PROVISION_TIMEOUT_MS = 120_000;
|
||||
import {
|
||||
DEFAULT_RUNTIME_PROFILE,
|
||||
provisionTimeoutForRuntime,
|
||||
} from "@/lib/runtimeProfiles";
|
||||
|
||||
/** Re-export for backward compatibility with tests and other importers
|
||||
* that previously imported DEFAULT_PROVISION_TIMEOUT_MS from this file.
|
||||
* New code should read via getRuntimeProfile() from @/lib/runtimeProfiles. */
|
||||
export const DEFAULT_PROVISION_TIMEOUT_MS =
|
||||
DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs;
|
||||
|
||||
/** The server provisions up to `PROVISION_CONCURRENCY` containers at
|
||||
* once and paces the rest in a queue (`workspaceCreatePacingMs` =
|
||||
@ -43,8 +49,12 @@ interface TimeoutEntry {
|
||||
* time per node.
|
||||
*/
|
||||
export function ProvisioningTimeout({
|
||||
timeoutMs = DEFAULT_PROVISION_TIMEOUT_MS,
|
||||
timeoutMs,
|
||||
}: {
|
||||
// If undefined (the default when mounted without a prop), each workspace's
|
||||
// threshold is resolved from its runtime via timeoutForRuntime().
|
||||
// Pass an explicit number to force a single threshold for every workspace
|
||||
// (used by tests that want deterministic behavior regardless of runtime).
|
||||
timeoutMs?: number;
|
||||
}) {
|
||||
const [timedOut, setTimedOut] = useState<TimeoutEntry[]>([]);
|
||||
@ -57,19 +67,28 @@ export function ProvisioningTimeout({
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
|
||||
|
||||
// Subscribe to provisioning nodes — use shallow compare to avoid infinite re-render
|
||||
// (filter+map creates new array reference on every store update)
|
||||
// (filter+map creates new array reference on every store update).
|
||||
// Runtime included so the timeout threshold can be resolved per-node
|
||||
// (hermes cold-boot legitimately takes 8-13 min vs 30-90s for docker
|
||||
// runtimes — a single threshold would false-alarm on one or the other).
|
||||
// Separator: `|` between fields, `,` between nodes. Names may contain
|
||||
// anything the user typed; strip `|` and `,` so serialization round-trips.
|
||||
const provisioningNodes = useCanvasStore((s) => {
|
||||
const result = s.nodes
|
||||
.filter((n) => n.data.status === "provisioning")
|
||||
.map((n) => `${n.id}:${n.data.name}`);
|
||||
.map((n) => {
|
||||
const safeName = (n.data.name ?? "").replace(/[|,]/g, " ");
|
||||
const runtime = n.data.runtime ?? "";
|
||||
return `${n.id}|${safeName}|${runtime}`;
|
||||
});
|
||||
return result.join(",");
|
||||
});
|
||||
const parsedProvisioningNodes = useMemo(
|
||||
() =>
|
||||
provisioningNodes
|
||||
? provisioningNodes.split(",").map((entry) => {
|
||||
const [id, name] = entry.split(":");
|
||||
return { id, name };
|
||||
const [id, name, runtime] = entry.split("|");
|
||||
return { id, name, runtime };
|
||||
})
|
||||
: [],
|
||||
[provisioningNodes],
|
||||
@ -113,14 +132,21 @@ export function ProvisioningTimeout({
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const newTimedOut: TimeoutEntry[] = [];
|
||||
const effective = effectiveTimeoutMs(
|
||||
timeoutMs,
|
||||
parsedProvisioningNodes.length,
|
||||
);
|
||||
|
||||
// Per-node timeout: each workspace resolves its own base via
|
||||
// @/lib/runtimeProfiles (server-override → runtime profile →
|
||||
// default), then scales by concurrent-provisioning count. A
|
||||
// hermes workspace in a batch alongside two langgraph workspaces
|
||||
// gets hermes's 12-min base, not langgraph's 2-min base.
|
||||
for (const node of parsedProvisioningNodes) {
|
||||
const startedAt = tracking.get(node.id);
|
||||
if (startedAt && now - startedAt >= effective) {
|
||||
if (!startedAt) continue;
|
||||
const base = timeoutMs ?? provisionTimeoutForRuntime(node.runtime);
|
||||
const effective = effectiveTimeoutMs(
|
||||
base,
|
||||
parsedProvisioningNodes.length,
|
||||
);
|
||||
if (now - startedAt >= effective) {
|
||||
newTimedOut.push({
|
||||
workspaceId: node.id,
|
||||
workspaceName: node.name,
|
||||
@ -284,6 +310,7 @@ export function ProvisioningTimeout({
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 mt-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRetry(entry.workspaceId)}
|
||||
disabled={isRetrying || isCancelling || retryCooldown.has(entry.workspaceId)}
|
||||
className="px-3 py-1.5 bg-amber-600 hover:bg-amber-500 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors"
|
||||
@ -291,6 +318,7 @@ export function ProvisioningTimeout({
|
||||
{isRetrying ? "Retrying..." : retryCooldown.has(entry.workspaceId) ? "Wait..." : "Retry"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCancelRequest(entry.workspaceId)}
|
||||
disabled={isRetrying || isCancelling}
|
||||
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-[11px] text-zinc-300 rounded-lg border border-zinc-600 disabled:opacity-40 transition-colors"
|
||||
@ -298,6 +326,7 @@ export function ProvisioningTimeout({
|
||||
{isCancelling ? "Cancelling..." : "Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleViewLogs(entry.workspaceId)}
|
||||
className="px-3 py-1.5 text-[11px] text-amber-400 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
@ -323,12 +352,14 @@ export function ProvisioningTimeout({
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingCancel(null)}
|
||||
className="px-3.5 py-1.5 text-[12px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelConfirm}
|
||||
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors"
|
||||
>
|
||||
|
||||
@ -132,6 +132,7 @@ export function SearchDialog() {
|
||||
) : (
|
||||
filtered.map((node, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={node.id}
|
||||
id={`search-result-${node.id}`}
|
||||
role="option"
|
||||
|
||||
@ -178,6 +178,7 @@ export function SidePanel() {
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectNode(null)}
|
||||
aria-label="Close workspace panel"
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/60 transition-colors"
|
||||
@ -221,6 +222,7 @@ export function SidePanel() {
|
||||
>
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.id}
|
||||
id={`tab-${tab.id}`}
|
||||
role="tab"
|
||||
@ -246,6 +248,7 @@ export function SidePanel() {
|
||||
<div className="px-4 py-2 bg-sky-950/20 border-b border-sky-800/20 flex items-center justify-between">
|
||||
<span className="text-[10px] text-sky-300/90">Config changed — restart to apply</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
useCanvasStore.getState().restartWorkspace(selectedNodeId).catch(() => showToast("Restart failed", "error"));
|
||||
}}
|
||||
|
||||
@ -175,6 +175,7 @@ export function OrgTemplatesSection() {
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadOrgs}
|
||||
aria-label="Refresh org templates"
|
||||
className="text-[10px] text-zinc-500 hover:text-zinc-300"
|
||||
@ -225,6 +226,7 @@ export function OrgTemplatesSection() {
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleImport(o)}
|
||||
disabled={isImporting}
|
||||
className="w-full px-2 py-1.5 bg-blue-600/20 hover:bg-blue-600/30 border border-blue-500/30 rounded-lg text-[10px] text-blue-300 font-medium transition-colors disabled:opacity-50"
|
||||
@ -300,6 +302,7 @@ function ImportAgentButton({ onImported }: { onImported: () => void }) {
|
||||
onChange={(e) => e.target.files && handleFiles(e.target.files)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={importing}
|
||||
className="w-full px-3 py-2 bg-blue-600/20 hover:bg-blue-600/30 border border-blue-500/30 rounded-lg text-[11px] text-blue-300 font-medium transition-colors disabled:opacity-50"
|
||||
@ -421,6 +424,7 @@ export function TemplatePalette() {
|
||||
<>
|
||||
{/* Toggle button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors ${
|
||||
open
|
||||
@ -493,6 +497,7 @@ export function TemplatePalette() {
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={t.id}
|
||||
onClick={() => handleDeploy(t)}
|
||||
disabled={isDeploying}
|
||||
@ -537,6 +542,7 @@ export function TemplatePalette() {
|
||||
<div className="px-4 py-3 border-t border-zinc-800/60 space-y-3">
|
||||
<ImportAgentButton onImported={loadTemplates} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadTemplates}
|
||||
className="text-[10px] text-zinc-500 hover:text-zinc-300 transition-colors block"
|
||||
>
|
||||
|
||||
@ -102,6 +102,7 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
|
||||
{error && <p role="alert" className="mt-3 text-sm text-red-400">{error}</p>}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={accept}
|
||||
disabled={submitting}
|
||||
className="rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||
|
||||
@ -63,6 +63,7 @@ export function Toaster() {
|
||||
<div key={toast.id} className={toastCls(toast.type)}>
|
||||
<span>{toast.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(toast.id)}
|
||||
aria-label="Dismiss notification"
|
||||
className="ml-1 p-1 rounded hover:bg-zinc-700/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
|
||||
@ -90,6 +91,7 @@ export function Toaster() {
|
||||
<div key={toast.id} className={toastCls(toast.type)}>
|
||||
<span>{toast.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(toast.id)}
|
||||
aria-label="Dismiss notification"
|
||||
className="ml-1 p-1 rounded hover:bg-zinc-700/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
|
||||
|
||||
@ -168,6 +168,7 @@ export function Toolbar() {
|
||||
{/* Stop All — visible when agents have active tasks */}
|
||||
{counts.activeTasks > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopAll}
|
||||
disabled={stopping}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-red-950/50 hover:bg-red-900/60 border border-red-800/40 rounded-lg transition-colors disabled:opacity-50"
|
||||
@ -186,6 +187,7 @@ export function Toolbar() {
|
||||
{/* Restart All — only shows when workspaces are flagged as needsRestart */}
|
||||
{needsRestartNodes.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRestartConfirmOpen(true)}
|
||||
disabled={restartingAll}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-amber-950/40 hover:bg-amber-900/50 border border-amber-800/40 rounded-lg transition-colors disabled:opacity-50"
|
||||
@ -208,6 +210,7 @@ export function Toolbar() {
|
||||
|
||||
{/* A2A topology overlay toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowA2AEdges(!showA2AEdges)}
|
||||
aria-pressed={showA2AEdges}
|
||||
aria-label={showA2AEdges ? "Hide A2A edges" : "Show A2A edges"}
|
||||
@ -241,6 +244,7 @@ export function Toolbar() {
|
||||
|
||||
{/* Audit trail shortcut — switches selected workspace's panel to the Audit tab */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (selectedNodeId) {
|
||||
setPanelTab("audit");
|
||||
@ -268,6 +272,7 @@ export function Toolbar() {
|
||||
|
||||
{/* Search shortcut */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => useCanvasStore.getState().setSearchOpen(true)}
|
||||
aria-label="Search workspaces"
|
||||
title="Search (⌘K)"
|
||||
@ -282,6 +287,7 @@ export function Toolbar() {
|
||||
{/* Quick help */}
|
||||
<div ref={helpRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHelpOpen((open) => !open)}
|
||||
className="flex items-center justify-center w-7 h-7 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors text-zinc-500 hover:text-zinc-300"
|
||||
aria-expanded={helpOpen}
|
||||
@ -299,6 +305,7 @@ export function Toolbar() {
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.24em] text-zinc-400">Quick start</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHelpOpen(false)}
|
||||
className="text-[10px] text-zinc-600 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Handle, NodeResizer, Position, type NodeProps, type Node } from "@xyflow/react";
|
||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
@ -269,6 +269,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
{/* Needs restart banner */}
|
||||
{data.needsRestart && !data.currentTask && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
useCanvasStore.getState().restartWorkspace(id).catch(() => showToast("Restart failed", "error"));
|
||||
@ -336,6 +337,172 @@ function countDescendants(nodeId: string, allNodes: Node<WorkspaceNodeData>[], v
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Maximum nesting depth for recursive TeamMemberChip rendering — prevents
|
||||
* infinite recursion on circular parentId references and keeps the UI readable. */
|
||||
const MAX_NESTING_DEPTH = 3;
|
||||
|
||||
/** Recursive mini-card — mirrors parent card layout at smaller scale */
|
||||
function TeamMemberChip({
|
||||
node,
|
||||
allNodes,
|
||||
depth,
|
||||
onSelect,
|
||||
onExtract,
|
||||
}: {
|
||||
node: Node<WorkspaceNodeData>;
|
||||
allNodes: Node<WorkspaceNodeData>[];
|
||||
depth: number;
|
||||
onSelect: (id: string) => void;
|
||||
onExtract: (id: string) => void;
|
||||
}) {
|
||||
const { data } = node;
|
||||
const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.offline;
|
||||
const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-zinc-500 bg-zinc-800" };
|
||||
const isOnline = data.status === "online";
|
||||
const skills = getSkillNames(data.agentCard);
|
||||
|
||||
const subChildren = useMemo(
|
||||
() => allNodes.filter((n) => n.data.parentId === node.id),
|
||||
[allNodes, node.id]
|
||||
);
|
||||
const hasSubChildren = subChildren.length > 0;
|
||||
const descendantCount = useMemo(
|
||||
() => hasSubChildren ? countDescendants(node.id, allNodes) : 0,
|
||||
[allNodes, node.id, hasSubChildren]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Select ${data.name}`}
|
||||
className="group/child relative rounded-lg bg-zinc-800/60 hover:bg-zinc-700/70 border border-zinc-700/30 hover:border-zinc-600/40 overflow-hidden transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(node.id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelect(node.id);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
useCanvasStore.getState().openContextMenu({ x: e.clientX, y: e.clientY, nodeId: node.id, nodeData: data });
|
||||
}}
|
||||
>
|
||||
{/* Status gradient bar */}
|
||||
<div className={`absolute inset-x-0 top-0 h-5 bg-gradient-to-b ${statusCfg.bar} pointer-events-none`} />
|
||||
|
||||
<div className="relative px-2 py-1.5">
|
||||
{/* Header: name + badges + extract */}
|
||||
<div className="flex items-center justify-between gap-1 mb-0.5">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${statusCfg.dot}`} />
|
||||
<span className="text-[10px] font-semibold text-zinc-200 truncate leading-tight">
|
||||
{data.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{hasSubChildren && (
|
||||
<span className="text-[7px] font-mono text-violet-300 bg-violet-900/40 border border-violet-700/30 px-1 py-0.5 rounded">
|
||||
{descendantCount}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-[7px] font-mono px-1 py-0.5 rounded ${tierCfg.color}`}>
|
||||
{tierCfg.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Extract ${data.name} from team`}
|
||||
title={`Extract ${data.name} from team`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExtract(node.id);
|
||||
}}
|
||||
className="opacity-0 group-hover/child:opacity-100 text-zinc-500 hover:text-sky-400 transition-all focus-visible:ring-2 focus-visible:ring-blue-500/70 focus-visible:outline-none rounded"
|
||||
>
|
||||
<EjectIcon aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
{data.role && (
|
||||
<div className="text-[10px] text-zinc-500 mb-1 leading-tight truncate">{data.role}</div>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
{skills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-0.5 mb-1">
|
||||
{skills.slice(0, 3).map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className={`text-[10px] px-1 py-0.5 rounded border ${
|
||||
isOnline
|
||||
? "text-emerald-300/70 bg-emerald-950/20 border-emerald-800/20"
|
||||
: "text-zinc-500 bg-zinc-800/40 border-zinc-700/30"
|
||||
}`}
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
{skills.length > 3 && (
|
||||
<span className="text-[10px] text-zinc-400 self-center">+{skills.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status + active tasks row */}
|
||||
<div className="flex items-center justify-between">
|
||||
{data.status !== "online" ? (
|
||||
<span className={`text-[10px] uppercase tracking-widest font-medium ${
|
||||
data.status === "failed" ? "text-red-400" :
|
||||
data.status === "degraded" ? "text-amber-300" :
|
||||
data.status === "provisioning" ? "text-sky-400" :
|
||||
"text-zinc-500"
|
||||
}`}>
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
) : <div />}
|
||||
{data.activeTasks > 0 && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="w-1 h-1 rounded-full bg-amber-400 motion-safe:animate-pulse" />
|
||||
<span className="text-[10px] text-amber-300 tabular-nums">
|
||||
{data.activeTasks}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current task banner for sub-agents */}
|
||||
{data.currentTask && (
|
||||
<Tooltip text={String(data.currentTask)}>
|
||||
<div className="flex items-center gap-1 mt-0.5 px-1.5 py-0.5 bg-amber-950/20 rounded border border-amber-800/20 cursor-default">
|
||||
<div className="w-1 h-1 rounded-full bg-amber-400 motion-safe:animate-pulse shrink-0" />
|
||||
<span className="text-[10px] text-amber-300 truncate">{data.currentTask}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Recursive sub-children rendered inside this card */}
|
||||
{hasSubChildren && depth < MAX_NESTING_DEPTH && (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-zinc-700/20">
|
||||
<div className="text-[10px] text-zinc-400 uppercase tracking-widest mb-1">Team</div>
|
||||
<div className={subChildren.length >= 2 ? "grid grid-cols-2 gap-1" : "space-y-1"}>
|
||||
{subChildren.map((sub) => (
|
||||
<TeamMemberChip key={sub.id} node={sub} allNodes={allNodes} depth={depth + 1} onSelect={onSelect} onExtract={onExtract} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getSkillNames(agentCard: Record<string, unknown> | null): string[] {
|
||||
if (!agentCard) return [];
|
||||
|
||||
@ -105,10 +105,64 @@ describe("AuthGate — authenticated state", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthGate — /cp/auth/* skip guard (redirect loop regression)", () => {
|
||||
it("renders children without calling fetchSession or redirect when pathname starts with /cp/auth/", async () => {
|
||||
mockGetTenantSlug.mockReturnValue("acme");
|
||||
mockFetchSession.mockResolvedValue(null);
|
||||
|
||||
// Simulate being on the login page
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: { ...window.location, pathname: "/cp/auth/login" },
|
||||
});
|
||||
|
||||
let result: ReturnType<typeof render>;
|
||||
await act(async () => {
|
||||
result = render(
|
||||
<AuthGate>
|
||||
<div data-testid="child">Protected content</div>
|
||||
</AuthGate>
|
||||
);
|
||||
});
|
||||
|
||||
// Children should render — AuthGate skips session fetch for auth paths
|
||||
expect(result!.getByTestId("child")).toBeTruthy();
|
||||
expect(mockFetchSession).not.toHaveBeenCalled();
|
||||
expect(mockRedirectToLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders children without calling redirect for /cp/auth/signup path", async () => {
|
||||
mockGetTenantSlug.mockReturnValue("acme");
|
||||
mockFetchSession.mockResolvedValue(null);
|
||||
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: { ...window.location, pathname: "/cp/auth/signup" },
|
||||
});
|
||||
|
||||
let result: ReturnType<typeof render>;
|
||||
await act(async () => {
|
||||
result = render(
|
||||
<AuthGate>
|
||||
<div data-testid="child">Protected content</div>
|
||||
</AuthGate>
|
||||
);
|
||||
});
|
||||
|
||||
expect(result!.getByTestId("child")).toBeTruthy();
|
||||
expect(mockRedirectToLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthGate — anonymous / redirect state", () => {
|
||||
it("calls redirectToLogin when session fetch returns null", async () => {
|
||||
mockGetTenantSlug.mockReturnValue("acme");
|
||||
mockFetchSession.mockResolvedValue(null);
|
||||
// Ensure pathname is NOT on /cp/auth/* so the redirect guard fires
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: { ...window.location, pathname: "/dashboard" },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
|
||||
@ -72,6 +72,7 @@ const mockStoreState = {
|
||||
selectedNodeIds: new Set<string>(),
|
||||
clearSelection: vi.fn(),
|
||||
toggleNodeSelection: vi.fn(),
|
||||
deletingIds: new Set<string>(),
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
|
||||
@ -16,7 +16,9 @@ afterEach(() => {
|
||||
// ── Shared fitView spy — must be set up before vi.mock hoisting ──────────────
|
||||
const mockFitView = vi.fn();
|
||||
const mockFitBounds = vi.fn();
|
||||
const mockGetIntersectingNodes = vi.fn(() => []);
|
||||
const mockGetIntersectingNodes = vi.fn(
|
||||
(): Array<{ id: string; position: { x: number; y: number } }> => [],
|
||||
);
|
||||
|
||||
vi.mock("@xyflow/react", () => {
|
||||
const ReactFlow = ({
|
||||
@ -83,6 +85,12 @@ const mockStoreState = {
|
||||
selectedNodeIds: new Set<string>(),
|
||||
clearSelection: vi.fn(),
|
||||
toggleNodeSelection: vi.fn(),
|
||||
// Cascade-delete / deploy animation state (added in the multilevel-
|
||||
// layout-UX bundle). Canvas.tsx reads deletingIds.size to decide
|
||||
// whether to apply the "locked during delete" class on each node;
|
||||
// an empty Set mirrors the idle canvas and doesn't interact with
|
||||
// any pan/fit behaviour under test here.
|
||||
deletingIds: new Set<string>(),
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
|
||||
@ -8,6 +8,12 @@ global.fetch = vi.fn(() =>
|
||||
import { useCanvasStore } from "../../store/canvas";
|
||||
import type { WorkspaceData } from "../../store/socket";
|
||||
import { DEFAULT_PROVISION_TIMEOUT_MS } from "../ProvisioningTimeout";
|
||||
import {
|
||||
DEFAULT_RUNTIME_PROFILE,
|
||||
RUNTIME_PROFILES,
|
||||
getRuntimeProfile,
|
||||
provisionTimeoutForRuntime,
|
||||
} from "@/lib/runtimeProfiles";
|
||||
|
||||
// Helper to build a WorkspaceData object
|
||||
function makeWS(overrides: Partial<WorkspaceData> & { id: string }): WorkspaceData {
|
||||
@ -184,4 +190,102 @@ describe("ProvisioningTimeout", () => {
|
||||
.nodes.filter((n) => n.data.status === "provisioning");
|
||||
expect(stillProvisioning).toHaveLength(2);
|
||||
});
|
||||
|
||||
// ── Runtime-aware timeout regression tests (2026-04-24 outage) ────────────
|
||||
// Prior to this, a hermes workspace consistently false-alarmed at 2 min
|
||||
// into its 8-13 min cold boot, pushing users to retry something that
|
||||
// would have come online on its own. The runtime-aware override keeps
|
||||
// the 2-min floor for fast docker runtimes while giving hermes its
|
||||
// honest 12-min budget.
|
||||
|
||||
describe("runtime profile resolution (@/lib/runtimeProfiles)", () => {
|
||||
describe("provisionTimeoutForRuntime", () => {
|
||||
it("returns the default for unknown/missing runtimes", () => {
|
||||
expect(provisionTimeoutForRuntime(undefined)).toBe(
|
||||
DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs,
|
||||
);
|
||||
expect(provisionTimeoutForRuntime("")).toBe(
|
||||
DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs,
|
||||
);
|
||||
expect(provisionTimeoutForRuntime("some-future-runtime")).toBe(
|
||||
DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns default for known-fast runtimes (not in profile map)", () => {
|
||||
// If someone ever adds one of these to RUNTIME_PROFILES with a
|
||||
// slower value, this test catches the unintended regression.
|
||||
expect(provisionTimeoutForRuntime("claude-code")).toBe(
|
||||
DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs,
|
||||
);
|
||||
expect(provisionTimeoutForRuntime("langgraph")).toBe(
|
||||
DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs,
|
||||
);
|
||||
expect(provisionTimeoutForRuntime("crewai")).toBe(
|
||||
DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns hermes override when runtime = hermes", () => {
|
||||
expect(provisionTimeoutForRuntime("hermes")).toBe(
|
||||
RUNTIME_PROFILES.hermes?.provisionTimeoutMs,
|
||||
);
|
||||
expect(provisionTimeoutForRuntime("hermes")).toBeGreaterThanOrEqual(
|
||||
DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs * 5,
|
||||
);
|
||||
});
|
||||
|
||||
it("server-side workspace override wins over runtime profile", () => {
|
||||
// The resolution order is: overrides → profile → default.
|
||||
// An operator-tunable per-workspace number on the backend
|
||||
// (e.g. via a template manifest field) should beat the canvas
|
||||
// runtime map.
|
||||
expect(
|
||||
provisionTimeoutForRuntime("hermes", {
|
||||
provisionTimeoutMs: 60_000,
|
||||
}),
|
||||
).toBe(60_000);
|
||||
expect(
|
||||
provisionTimeoutForRuntime("some-unknown", {
|
||||
provisionTimeoutMs: 300_000,
|
||||
}),
|
||||
).toBe(300_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRuntimeProfile", () => {
|
||||
it("returns a structural profile with required fields", () => {
|
||||
const profile = getRuntimeProfile("hermes");
|
||||
expect(profile.provisionTimeoutMs).toBeTypeOf("number");
|
||||
expect(profile.provisionTimeoutMs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("default profile is a valid superset of every override", () => {
|
||||
// Every entry in RUNTIME_PROFILES must provide fields the
|
||||
// default does — otherwise consumers could get undefined where
|
||||
// they expected a number. This test enforces that contract so
|
||||
// future entries can't accidentally drop fields.
|
||||
for (const [runtime, profile] of Object.entries(RUNTIME_PROFILES)) {
|
||||
const resolved = getRuntimeProfile(runtime);
|
||||
expect(
|
||||
resolved.provisionTimeoutMs,
|
||||
`runtime=${runtime} must resolve to a number`,
|
||||
).toBeTypeOf("number");
|
||||
expect(resolved.provisionTimeoutMs).toBeGreaterThan(0);
|
||||
// Profile's explicit value should be used iff present.
|
||||
if (profile.provisionTimeoutMs !== undefined) {
|
||||
expect(resolved.provisionTimeoutMs).toBe(profile.provisionTimeoutMs);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_PROVISION_TIMEOUT_MS backward-compat export", () => {
|
||||
it("still exports the same default for legacy importers", () => {
|
||||
expect(DEFAULT_PROVISION_TIMEOUT_MS).toBe(
|
||||
DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -183,7 +183,31 @@ describe("ChannelsTab — htmlFor/id label associations (WCAG 1.3.1)", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockImplementation((url: string) => {
|
||||
if (url.includes("/channels/adapters")) {
|
||||
return Promise.resolve([{ type: "telegram", display_name: "Telegram" }]);
|
||||
// Mirror the real GET /channels/adapters shape — schema-driven form
|
||||
// relies on config_schema arriving from the adapter. A bare
|
||||
// {type, display_name} mock renders an empty form and every
|
||||
// getByLabelText below fails.
|
||||
return Promise.resolve([
|
||||
{
|
||||
type: "telegram",
|
||||
display_name: "Telegram",
|
||||
config_schema: [
|
||||
{
|
||||
key: "bot_token",
|
||||
label: "Bot Token",
|
||||
type: "password",
|
||||
required: true,
|
||||
sensitive: true,
|
||||
},
|
||||
{
|
||||
key: "chat_id",
|
||||
label: "Chat IDs",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
@ -125,6 +125,7 @@ export function OrgTokensTab() {
|
||||
onChange={(e) => setNameInput(e.target.value)}
|
||||
placeholder="Label (e.g. zapier, my-ci)"
|
||||
maxLength={100}
|
||||
aria-label="Organization API key label"
|
||||
className="flex-1 text-[11px] bg-zinc-900/60 border border-zinc-700/50 rounded px-2 py-1.5 text-zinc-200 placeholder-zinc-600"
|
||||
/>
|
||||
<button
|
||||
|
||||
@ -62,6 +62,7 @@ function GearIcon() {
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
|
||||
@ -31,12 +31,12 @@ export function UnsavedChangesGuard({
|
||||
</AlertDialog.Title>
|
||||
<div className="guard-dialog__actions">
|
||||
<AlertDialog.Cancel asChild>
|
||||
<button className="guard-dialog__keep-btn" onClick={onKeepEditing}>
|
||||
<button type="button" className="guard-dialog__keep-btn">
|
||||
Keep editing
|
||||
</button>
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action asChild>
|
||||
<button className="guard-dialog__discard-btn" onClick={onDiscard}>
|
||||
<button type="button" className="guard-dialog__discard-btn">
|
||||
Discard
|
||||
</button>
|
||||
</AlertDialog.Action>
|
||||
|
||||
@ -186,7 +186,7 @@ function ActivityRow({
|
||||
: "bg-zinc-800/60 border-zinc-700/40"
|
||||
}`}
|
||||
>
|
||||
<button onClick={onToggle} className="w-full text-left px-3 py-2">
|
||||
<button type="button" onClick={onToggle} className="w-full text-left px-3 py-2">
|
||||
{/* Top row: type badge + method + time */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[8px] font-mono px-1.5 py-0.5 rounded ${typeStyle.text} ${typeStyle.bg} border ${typeStyle.border}`}>
|
||||
|
||||
@ -4,9 +4,23 @@ import { useState, useEffect, useCallback, useId } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
|
||||
// ConfigField mirrors the Go struct returned by GET /channels/adapters —
|
||||
// the UI renders one input per field in the order the adapter returns
|
||||
// them, so per-platform form shape stays server-owned.
|
||||
interface ConfigField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "text" | "password" | "textarea";
|
||||
required: boolean;
|
||||
sensitive?: boolean;
|
||||
placeholder?: string;
|
||||
help?: string;
|
||||
}
|
||||
|
||||
interface ChannelAdapter {
|
||||
type: string;
|
||||
display_name: string;
|
||||
config_schema?: ConfigField[];
|
||||
}
|
||||
|
||||
interface Channel {
|
||||
@ -25,6 +39,11 @@ interface Props {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
// Telegram is the only platform that supports "Detect Chats" via
|
||||
// getUpdates. Every other platform uses a webhook URL that already
|
||||
// encodes the chat, so the button is only offered when useful.
|
||||
const SUPPORTS_DETECT_CHATS = new Set(["telegram"]);
|
||||
|
||||
function relativeTime(iso: string | null | undefined): string {
|
||||
if (!iso) return "never";
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
@ -41,11 +60,12 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<Channel | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Form state
|
||||
// Form state — schema-driven: formValues holds the typed-in config for
|
||||
// whichever adapter is currently selected, keyed by ConfigField.key.
|
||||
const [formType, setFormType] = useState("telegram");
|
||||
const [formBotToken, setFormBotToken] = useState("");
|
||||
const [formChatId, setFormChatId] = useState("");
|
||||
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
||||
const [formAllowedUsers, setFormAllowedUsers] = useState("");
|
||||
const [formError, setFormError] = useState("");
|
||||
const [discovering, setDiscovering] = useState(false);
|
||||
@ -53,18 +73,13 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
const [selectedChats, setSelectedChats] = useState<Set<string>>(new Set());
|
||||
const [showManualInput, setShowManualInput] = useState(false);
|
||||
|
||||
// Stable IDs for label↔input associations (WCAG 1.3.1)
|
||||
const platformId = useId();
|
||||
const botTokenId = useId();
|
||||
const chatIdId = useId();
|
||||
const allowedUsersId = useId();
|
||||
|
||||
const currentAdapter = adapters.find((a) => a.type === formType);
|
||||
const currentSchema: ConfigField[] = currentAdapter?.config_schema || [];
|
||||
|
||||
const load = useCallback(async () => {
|
||||
// Fetch channels and adapters independently so a failure in one
|
||||
// doesn't blank the other. Previously a single Promise.all + silent
|
||||
// catch meant ANY request failing left both `channels` and
|
||||
// `adapters` empty — the user saw a "+ Connect" button with no
|
||||
// platform options, with no clue why.
|
||||
const [chResult, adResult] = await Promise.allSettled([
|
||||
api.get<Channel[]>(`/workspaces/${workspaceId}/channels`),
|
||||
api.get<ChannelAdapter[]>(`/channels/adapters`),
|
||||
@ -82,8 +97,6 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
console.warn("ChannelsTab: adapters load failed", adResult.reason);
|
||||
errors.push("platforms");
|
||||
}
|
||||
// Surface BOTH failure modes so the user can distinguish
|
||||
// "no channels configured" from "API unreachable".
|
||||
if (errors.length > 0) {
|
||||
setError(`Failed to load ${errors.join(" and ")} — try refreshing`);
|
||||
} else {
|
||||
@ -100,8 +113,24 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
return () => clearInterval(interval);
|
||||
}, [load]);
|
||||
|
||||
// Reset form values when the selected platform changes — each platform
|
||||
// has a different field set, so reusing old values would leak stale
|
||||
// data across platforms.
|
||||
useEffect(() => {
|
||||
setFormValues({});
|
||||
setDiscoveredChats([]);
|
||||
setSelectedChats(new Set());
|
||||
setShowManualInput(false);
|
||||
setFormError("");
|
||||
}, [formType]);
|
||||
|
||||
const setFieldValue = (key: string, value: string) => {
|
||||
setFormValues((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleDiscover = async () => {
|
||||
if (!formBotToken) {
|
||||
const botToken = formValues["bot_token"] || "";
|
||||
if (!botToken) {
|
||||
setFormError("Enter a bot token first");
|
||||
return;
|
||||
}
|
||||
@ -111,16 +140,15 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
try {
|
||||
const res = await api.post<{ chats: { chat_id: string; name: string; type: string }[]; hint: string }>(
|
||||
`/channels/discover`,
|
||||
{ channel_type: formType, bot_token: formBotToken, workspace_id: workspaceId }
|
||||
{ channel_type: formType, bot_token: botToken, workspace_id: workspaceId }
|
||||
);
|
||||
const chats = res.chats || [];
|
||||
setDiscoveredChats(chats);
|
||||
if (chats.length === 0) {
|
||||
setFormError("No chats found. For groups: add the bot and send a message. For DMs: send /start to the bot first. Then retry.");
|
||||
} else {
|
||||
// Auto-select all discovered chats
|
||||
setSelectedChats(new Set(chats.map((c) => c.chat_id)));
|
||||
setFormChatId(chats.map((c) => c.chat_id).join(", "));
|
||||
setFieldValue("chat_id", chats.map((c) => c.chat_id).join(", "));
|
||||
}
|
||||
} catch (e) {
|
||||
setFormError(String(e));
|
||||
@ -134,15 +162,22 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
const next = new Set(prev);
|
||||
if (next.has(chatId)) next.delete(chatId);
|
||||
else next.add(chatId);
|
||||
setFormChatId(Array.from(next).join(", "));
|
||||
setFieldValue("chat_id", Array.from(next).join(", "));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
setFormError("");
|
||||
if (!formBotToken || !formChatId) {
|
||||
setFormError("Bot token and chat ID are required");
|
||||
// Client-side required-field check so the user sees the gap before
|
||||
// we round-trip to the server. ValidateConfig on the backend remains
|
||||
// authoritative — adapter-specific rules like "bot_token OR webhook_url"
|
||||
// for Slack aren't expressible in required-flag alone.
|
||||
const missing = currentSchema
|
||||
.filter((f) => f.required && !(formValues[f.key] || "").trim())
|
||||
.map((f) => f.label);
|
||||
if (missing.length > 0) {
|
||||
setFormError(`Required: ${missing.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@ -150,14 +185,20 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
// Only send keys the schema knows about — avoids accidentally
|
||||
// persisting stale values when the user switched platforms mid-edit.
|
||||
const config: Record<string, string> = {};
|
||||
for (const f of currentSchema) {
|
||||
const v = (formValues[f.key] || "").trim();
|
||||
if (v) config[f.key] = v;
|
||||
}
|
||||
await api.post(`/workspaces/${workspaceId}/channels`, {
|
||||
channel_type: formType,
|
||||
config: { bot_token: formBotToken, chat_id: formChatId },
|
||||
config,
|
||||
allowed_users: allowed,
|
||||
});
|
||||
setShowForm(false);
|
||||
setFormBotToken("");
|
||||
setFormChatId("");
|
||||
setFormValues({});
|
||||
setFormAllowedUsers("");
|
||||
load();
|
||||
} catch (e) {
|
||||
@ -165,8 +206,6 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleToggle = async (ch: Channel) => {
|
||||
try {
|
||||
await api.patch(`/workspaces/${workspaceId}/channels/${ch.id}`, {
|
||||
@ -228,7 +267,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create form */}
|
||||
{/* Create form — schema-driven */}
|
||||
{showForm && (
|
||||
<div className="space-y-2 p-3 bg-zinc-800/40 rounded border border-zinc-700/50">
|
||||
<div>
|
||||
@ -244,73 +283,69 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={botTokenId} className="text-[10px] text-zinc-500 block mb-1">Bot Token</label>
|
||||
<input
|
||||
id={botTokenId}
|
||||
type="password"
|
||||
value={formBotToken}
|
||||
onChange={(e) => setFormBotToken(e.target.value)}
|
||||
placeholder="123456:ABC-DEF..."
|
||||
className="w-full text-xs bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-zinc-300 placeholder-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label htmlFor={chatIdId} className="text-[10px] text-zinc-500">Chat IDs</label>
|
||||
<button
|
||||
onClick={handleDiscover}
|
||||
disabled={discovering || !formBotToken}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-blue-600/20 text-blue-400 hover:bg-blue-600/30 transition disabled:opacity-40"
|
||||
>
|
||||
{discovering ? "Detecting..." : "Detect Chats"}
|
||||
</button>
|
||||
|
||||
{/* Render one input per schema field. Fallback path: if the
|
||||
backend didn't return a schema (older platform version) show
|
||||
a single bot_token + chat_id pair to preserve the old UX. */}
|
||||
{currentSchema.length === 0 ? (
|
||||
<div className="text-[10px] text-yellow-500">
|
||||
Platform exposes no config schema — upgrade the platform to pick up first-class support.
|
||||
</div>
|
||||
{discoveredChats.length > 0 && (
|
||||
<div className="space-y-1 mb-2">
|
||||
{discoveredChats.map((chat) => (
|
||||
<label
|
||||
key={chat.chat_id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 bg-zinc-900/50 rounded border border-zinc-700/50 cursor-pointer hover:bg-zinc-800/50"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedChats.has(chat.chat_id)}
|
||||
onChange={() => toggleChat(chat.chat_id)}
|
||||
className="rounded border-zinc-600"
|
||||
/>
|
||||
<span className="text-xs text-zinc-300">{chat.name || "Unknown"}</span>
|
||||
<span className="text-[10px] text-zinc-500 ml-auto">{chat.type} {chat.chat_id}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(discoveredChats.length === 0 || showManualInput) && (
|
||||
<input
|
||||
id={chatIdId}
|
||||
value={formChatId}
|
||||
onChange={(e) => setFormChatId(e.target.value)}
|
||||
placeholder="-100123456789, -100987654321"
|
||||
className="w-full text-xs bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-zinc-300 placeholder-zinc-600"
|
||||
) : (
|
||||
currentSchema.map((field) => (
|
||||
<SchemaField
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={formValues[field.key] || ""}
|
||||
onChange={(v) => setFieldValue(field.key, v)}
|
||||
// Detect Chats button lives next to the chat_id input on
|
||||
// Telegram only (the only platform with getUpdates).
|
||||
renderExtras={
|
||||
field.key === "chat_id" && SUPPORTS_DETECT_CHATS.has(formType)
|
||||
? () => (
|
||||
<>
|
||||
<div className="flex items-center justify-end mb-1 -mt-1">
|
||||
<button
|
||||
onClick={handleDiscover}
|
||||
disabled={discovering || !formValues["bot_token"]}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-blue-600/20 text-blue-400 hover:bg-blue-600/30 transition disabled:opacity-40"
|
||||
>
|
||||
{discovering ? "Detecting..." : "Detect Chats"}
|
||||
</button>
|
||||
</div>
|
||||
{discoveredChats.length > 0 && (
|
||||
<div className="space-y-1 mb-2">
|
||||
{discoveredChats.map((chat) => (
|
||||
<label
|
||||
key={chat.chat_id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 bg-zinc-900/50 rounded border border-zinc-700/50 cursor-pointer hover:bg-zinc-800/50"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedChats.has(chat.chat_id)}
|
||||
onChange={() => toggleChat(chat.chat_id)}
|
||||
className="rounded border-zinc-600"
|
||||
/>
|
||||
<span className="text-xs text-zinc-300">{chat.name || "Unknown"}</span>
|
||||
<span className="text-[10px] text-zinc-500 ml-auto">{chat.type} {chat.chat_id}</span>
|
||||
</label>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setShowManualInput(!showManualInput)}
|
||||
className="text-[10px] text-blue-400 hover:underline"
|
||||
>
|
||||
{showManualInput ? "hide manual input" : "edit manually"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<p className="text-[11px] text-zinc-500 mt-0.5">
|
||||
{discoveredChats.length > 0 ? (
|
||||
<>
|
||||
Chats: <span className="text-zinc-400">{formChatId || "(none selected)"}</span>
|
||||
{" · "}
|
||||
<button
|
||||
onClick={() => setShowManualInput(!showManualInput)}
|
||||
className="text-blue-400 hover:underline"
|
||||
>
|
||||
{showManualInput ? "hide manual input" : "edit manually"}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
"Click Detect Chats after adding the bot to groups or sending /start in DMs."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor={allowedUsersId} className="text-[10px] text-zinc-500 block mb-1">
|
||||
Allowed Users <span className="text-zinc-600">(optional, comma-separated)</span>
|
||||
@ -323,7 +358,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
className="w-full text-xs bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-zinc-300 placeholder-zinc-600"
|
||||
/>
|
||||
<p className="text-[11px] text-zinc-500 mt-0.5">
|
||||
Telegram user IDs. Leave empty to allow everyone.
|
||||
Platform-specific user IDs. Leave empty to allow everyone.
|
||||
</p>
|
||||
</div>
|
||||
{formError && (
|
||||
@ -343,7 +378,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
<div className="text-center py-8">
|
||||
<p className="text-zinc-500 text-xs">No channels connected</p>
|
||||
<p className="text-zinc-600 text-[10px] mt-1">
|
||||
Connect Telegram, Slack, or Discord to chat with this agent from social platforms.
|
||||
Connect Telegram, Slack, Discord, or Lark / Feishu to chat with this agent from social platforms.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@ -364,7 +399,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
{ch.channel_type.charAt(0).toUpperCase() + ch.channel_type.slice(1)}
|
||||
</span>
|
||||
<span className="text-[10px] text-zinc-500">
|
||||
{ch.config.chat_id}
|
||||
{ch.config.chat_id || ch.config.channel_id || ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
@ -415,3 +450,53 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// SchemaField renders one ConfigField as a label + input. Kept inline in
|
||||
// this file so the ChannelsTab stays self-contained; promote to its own
|
||||
// module if another tab ever needs it.
|
||||
function SchemaField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
renderExtras,
|
||||
}: {
|
||||
field: ConfigField;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
renderExtras?: () => React.ReactNode;
|
||||
}) {
|
||||
const inputId = useId();
|
||||
const common =
|
||||
"w-full text-xs bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-zinc-300 placeholder-zinc-600";
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={inputId} className="text-[10px] text-zinc-500 block mb-1">
|
||||
{field.label}
|
||||
{!field.required && <span className="text-zinc-600"> (optional)</span>}
|
||||
</label>
|
||||
{field.type === "textarea" ? (
|
||||
<textarea
|
||||
id={inputId}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
rows={3}
|
||||
className={common}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={inputId}
|
||||
type={field.type === "password" ? "password" : "text"}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className={common}
|
||||
/>
|
||||
)}
|
||||
{renderExtras?.()}
|
||||
{field.help && (
|
||||
<p className="text-[11px] text-zinc-500 mt-0.5">{field.help}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -51,17 +51,18 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) {
|
||||
) : editing ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
aria-label="Agent card JSON editor"
|
||||
value={draft} onChange={(e) => setDraft(e.target.value)}
|
||||
spellCheck={false} rows={12}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded p-2 text-[10px] font-mono text-zinc-200 focus:outline-none focus:border-blue-500 resize-none"
|
||||
/>
|
||||
{error && <div className="px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-red-400">{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleSave} disabled={saving}
|
||||
<button type="button" onClick={handleSave} disabled={saving}
|
||||
className="px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white disabled:opacity-50">
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button onClick={() => setEditing(false)}
|
||||
<button type="button" onClick={() => setEditing(false)}
|
||||
className="px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -75,7 +76,7 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) {
|
||||
<div className="text-[10px] text-zinc-500">No agent card</div>
|
||||
)}
|
||||
{success && <div className="mt-2 px-2 py-1 bg-green-900/30 border border-green-800 rounded text-[10px] text-green-400">Updated</div>}
|
||||
<button onClick={() => { setDraft(JSON.stringify(card || {}, null, 2)); setEditing(true); setError(null); setSuccess(false); }}
|
||||
<button type="button" onClick={() => { setDraft(JSON.stringify(card || {}, null, 2)); setEditing(true); setError(null); setSuccess(false); }}
|
||||
className="mt-2 text-[10px] text-blue-400 hover:text-blue-300">Edit Agent Card</button>
|
||||
</div>
|
||||
)}
|
||||
@ -384,6 +385,7 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
{rawMode ? (
|
||||
<div className="flex-1 p-3">
|
||||
<textarea
|
||||
aria-label="Raw YAML editor"
|
||||
value={rawDraft}
|
||||
onChange={(e) => setRawDraft(e.target.value)}
|
||||
spellCheck={false}
|
||||
@ -645,6 +647,7 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
|
||||
<div className="p-3 border-t border-zinc-800 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSave(true)}
|
||||
disabled={!isDirty || saving}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-xs rounded text-white disabled:opacity-30 transition-colors"
|
||||
@ -652,6 +655,7 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
{saving ? "Restarting..." : "Save & Restart"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSave(false)}
|
||||
disabled={!isDirty || saving}
|
||||
className="px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300 disabled:opacity-30 transition-colors"
|
||||
@ -659,6 +663,7 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadConfig}
|
||||
className="px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300 ml-auto"
|
||||
>
|
||||
|
||||
@ -159,6 +159,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
)}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-xs rounded text-white disabled:opacity-50"
|
||||
@ -166,6 +167,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setSaveError(null);
|
||||
@ -199,6 +201,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRestart}
|
||||
disabled={restarting}
|
||||
className="px-3 py-1 bg-green-700 hover:bg-green-600 text-xs rounded text-white disabled:opacity-50"
|
||||
@ -208,6 +211,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
className="mt-2 px-3 py-1 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300"
|
||||
>
|
||||
@ -234,6 +238,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
<p className="text-xs text-zinc-500">No error detail recorded.</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConsoleOpen(true)}
|
||||
className="mt-2 px-3 py-1 bg-zinc-800 hover:bg-zinc-700 text-xs rounded text-zinc-300 border border-zinc-700"
|
||||
>
|
||||
@ -279,6 +284,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
{peers.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => selectNode(p.id)}
|
||||
className="w-full flex items-center gap-2 px-2 py-1 rounded hover:bg-zinc-800 text-left"
|
||||
>
|
||||
@ -310,12 +316,14 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="px-3 py-1 bg-red-600 hover:bg-red-500 text-xs rounded text-white"
|
||||
>
|
||||
Confirm Delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConfirmDelete(false);
|
||||
setDeleteError(null);
|
||||
@ -330,6 +338,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
ref={deleteButtonRef}
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="px-3 py-1 bg-zinc-800 hover:bg-red-900 border border-zinc-700 hover:border-red-700 text-xs rounded text-zinc-400 hover:text-red-400 transition-colors"
|
||||
|
||||
@ -165,8 +165,8 @@ export function FilesTab({ workspaceId }: Props) {
|
||||
<div className="mx-3 mt-2 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded space-y-1.5">
|
||||
<p className="text-xs text-red-300">Delete all {files.filter((f) => !f.dir).length} files? This cannot be undone.</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => { handleDeleteAll(); setShowDeleteAll(false); }} className="px-2 py-0.5 bg-red-600 hover:bg-red-500 text-[10px] rounded text-white">Delete All</button>
|
||||
<button onClick={() => setShowDeleteAll(false)} className="px-2 py-0.5 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300">Cancel</button>
|
||||
<button type="button" onClick={() => { handleDeleteAll(); setShowDeleteAll(false); }} className="px-2 py-0.5 bg-red-600 hover:bg-red-500 text-[10px] rounded text-white">Delete All</button>
|
||||
<button type="button" onClick={() => setShowDeleteAll(false)} className="px-2 py-0.5 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -179,8 +179,8 @@ export function FilesTab({ workspaceId }: Props) {
|
||||
<div className="mx-3 mt-2 px-3 py-2 bg-amber-950/30 border border-amber-800/40 rounded space-y-1.5">
|
||||
<p className="text-xs text-amber-300">Delete <span className="font-mono">{confirmDelete}</span>{files.find((f) => f.path === confirmDelete && f.dir) ? " and all its contents" : ""}?</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={confirmDeleteFile} className="px-2 py-0.5 bg-red-600 hover:bg-red-500 text-[10px] rounded text-white">Delete</button>
|
||||
<button onClick={() => setConfirmDelete(null)} className="px-2 py-0.5 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300">Cancel</button>
|
||||
<button type="button" onClick={confirmDeleteFile} className="px-2 py-0.5 bg-red-600 hover:bg-red-500 text-[10px] rounded text-white">Delete</button>
|
||||
<button type="button" onClick={() => setConfirmDelete(null)} className="px-2 py-0.5 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -44,7 +44,7 @@ export function FilesToolbar({
|
||||
<div className="flex gap-1.5">
|
||||
{root === "/configs" && (
|
||||
<>
|
||||
<button onClick={onNewFile} aria-label="Create new file" className="text-[10px] text-blue-400 hover:text-blue-300" title="Create new file">
|
||||
<button type="button" onClick={onNewFile} aria-label="Create new file" className="text-[10px] text-blue-400 hover:text-blue-300" title="Create new file">
|
||||
+ New
|
||||
</button>
|
||||
<input
|
||||
@ -57,20 +57,20 @@ export function FilesToolbar({
|
||||
className="hidden"
|
||||
onChange={(e) => e.target.files && onUpload(e.target.files)}
|
||||
/>
|
||||
<button onClick={() => uploadRef.current?.click()} aria-label="Upload folder" className="text-[10px] text-blue-400 hover:text-blue-300" title="Upload folder">
|
||||
<button type="button" onClick={() => uploadRef.current?.click()} aria-label="Upload folder" className="text-[10px] text-blue-400 hover:text-blue-300" title="Upload folder">
|
||||
Upload
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={onDownloadAll} aria-label="Download all files" className="text-[10px] text-zinc-500 hover:text-zinc-300" title="Download all files">
|
||||
<button type="button" onClick={onDownloadAll} aria-label="Download all files" className="text-[10px] text-zinc-500 hover:text-zinc-300" title="Download all files">
|
||||
Export
|
||||
</button>
|
||||
{root === "/configs" && (
|
||||
<button onClick={onClearAll} aria-label="Delete all files" className="text-[10px] text-red-400/60 hover:text-red-400" title="Delete all files">
|
||||
<button type="button" onClick={onClearAll} aria-label="Delete all files" className="text-[10px] text-red-400/60 hover:text-red-400" title="Delete all files">
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onRefresh} aria-label="Refresh file list" className="text-[10px] text-zinc-500 hover:text-zinc-300" title="Refresh">
|
||||
<button type="button" onClick={onRefresh} aria-label="Refresh file list" className="text-[10px] text-zinc-500 hover:text-zinc-300" title="Refresh">
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -135,12 +135,14 @@ export function MemoryTab({ workspaceId }: Props) {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAwareness((prev) => !prev)}
|
||||
className="shrink-0 px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-200"
|
||||
>
|
||||
{showAwareness ? "Collapse" : "Expand"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAwareness}
|
||||
className="shrink-0 px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-200"
|
||||
>
|
||||
@ -173,6 +175,7 @@ export function MemoryTab({ workspaceId }: Props) {
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAwareness(true)}
|
||||
className="shrink-0 px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white"
|
||||
>
|
||||
@ -207,18 +210,21 @@ export function MemoryTab({ workspaceId }: Props) {
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((prev) => !prev)}
|
||||
className="px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300"
|
||||
>
|
||||
{showAdvanced ? "Hide Advanced" : "Advanced"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMemory}
|
||||
className="px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowAdd(!showAdd); if (!showAdd) setShowAdvanced(true); }}
|
||||
className="px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white"
|
||||
>
|
||||
@ -254,12 +260,14 @@ export function MemoryTab({ workspaceId }: Props) {
|
||||
{error && <div role="alert" className="text-xs text-red-400">{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-xs rounded text-white"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowAdd(false);
|
||||
setError(null);
|
||||
@ -280,6 +288,7 @@ export function MemoryTab({ workspaceId }: Props) {
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.key} className="bg-zinc-800 rounded border border-zinc-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(expanded === entry.key ? null : entry.key)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-left"
|
||||
aria-expanded={expanded === entry.key}
|
||||
@ -307,6 +316,7 @@ export function MemoryTab({ workspaceId }: Props) {
|
||||
Updated: {new Date(entry.updated_at).toLocaleString()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(entry.key)}
|
||||
className="text-[10px] text-red-400 hover:text-red-300"
|
||||
>
|
||||
@ -328,6 +338,7 @@ export function MemoryTab({ workspaceId }: Props) {
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(true)}
|
||||
className="shrink-0 px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white"
|
||||
>
|
||||
|
||||
@ -55,7 +55,7 @@ export function TracesTab({ workspaceId }: Props) {
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-zinc-400">{traces.length} traces</span>
|
||||
<button onClick={loadTraces} className="text-[10px] text-zinc-500 hover:text-zinc-300">
|
||||
<button type="button" onClick={loadTraces} className="text-[10px] text-zinc-500 hover:text-zinc-300">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -128,7 +128,13 @@ describe("ConfigTab — hermes workspace", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows hermes-specific info banner pointing to Terminal tab (#1894)", async () => {
|
||||
it("does NOT show the hermes-specific info banner (removed in #2061)", async () => {
|
||||
// Banner-text inversion: the multilevel-layout-UX PR drops "hermes"
|
||||
// from RUNTIMES_WITH_OWN_CONFIG (now {"external"} only). Hermes now
|
||||
// shows the normal Config form — the banner "Hermes manages its own
|
||||
// config" is reserved for the "external" runtime, not hermes itself.
|
||||
// If this ever flips back, revisit the banner/error UX before
|
||||
// unpinning this assertion.
|
||||
wireApi({
|
||||
workspaceRuntime: "hermes",
|
||||
configYamlContent: null,
|
||||
@ -137,9 +143,11 @@ describe("ConfigTab — hermes workspace", () => {
|
||||
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Hermes manages its own config/i)).toBeTruthy();
|
||||
});
|
||||
// Wait for the render+loads to settle (template list drives the runtime combobox).
|
||||
await waitFor(() =>
|
||||
screen.getByRole("combobox", { name: /runtime/i }),
|
||||
);
|
||||
expect(screen.queryByText(/Hermes manages its own config/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("DOES show 'No config.yaml found' error for langgraph workspace (default runtime)", async () => {
|
||||
@ -161,14 +169,28 @@ describe("ConfigTab — hermes workspace", () => {
|
||||
});
|
||||
|
||||
describe("ConfigTab — config.yaml on disk", () => {
|
||||
it("config.yaml runtime/model wins when present, workspace metadata is fallback", async () => {
|
||||
// If the workspace DB has runtime=langgraph but config.yaml declares
|
||||
// runtime: crewai, the form should show crewai (config.yaml wins).
|
||||
// Prevents silent runtime drift across reads.
|
||||
it("workspace metadata (DB) wins over config.yaml when both are present (#2061)", async () => {
|
||||
// Priority inversion in #2061: previously config.yaml overrode DB, so
|
||||
// the tier-on-node badge and runtime-in-form could drift when the
|
||||
// user edited config.yaml on disk. The multilevel-layout-UX PR made
|
||||
// the DB authoritative — config.yaml is read for non-DB keys (tools,
|
||||
// MCP server list, etc.) but runtime/model/tier come from the
|
||||
// workspace row so the node badge matches the form.
|
||||
//
|
||||
// Scenario: DB says "hermes", config.yaml says "crewai". The form
|
||||
// must show hermes (DB wins).
|
||||
//
|
||||
// We pick hermes (not langgraph) on the DB side because "langgraph"
|
||||
// is collapsed to the empty-string "LangGraph (default)" option in
|
||||
// the runtime dropdown — so a "langgraph" DB value would render as
|
||||
// the empty-valued option and obscure whether the DB-wins logic
|
||||
// actually fired. Hermes has its own non-empty option value and
|
||||
// gives the assertion a clean signal.
|
||||
wireApi({
|
||||
workspaceRuntime: "langgraph", // DB
|
||||
workspaceRuntime: "hermes", // DB — authoritative
|
||||
configYamlContent: 'runtime: crewai\nmodel: "claude-opus"\n',
|
||||
templates: [
|
||||
{ id: "t-hermes", name: "Hermes", runtime: "hermes", models: [] },
|
||||
{ id: "t-crewai", name: "CrewAI", runtime: "crewai", models: [] },
|
||||
],
|
||||
});
|
||||
@ -176,6 +198,6 @@ describe("ConfigTab — config.yaml on disk", () => {
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
|
||||
const select = await waitFor(() => screen.getByRole("combobox", { name: /runtime/i }));
|
||||
expect((select as HTMLSelectElement).value).toBe("crewai");
|
||||
expect((select as HTMLSelectElement).value).toBe("hermes");
|
||||
});
|
||||
});
|
||||
|
||||
@ -49,14 +49,17 @@ export const DEFAULT_CONFIG: ConfigData = {
|
||||
};
|
||||
|
||||
export function TextInput({ label, value, onChange, placeholder, mono }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string; mono?: boolean }) {
|
||||
const id = `textinput-${label.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
return (
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">{label}</label>
|
||||
<label htmlFor={id} className="text-[10px] text-zinc-500 block mb-1">{label}</label>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={label}
|
||||
className={`w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500 ${mono ? "font-mono" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
@ -64,15 +67,18 @@ export function TextInput({ label, value, onChange, placeholder, mono }: { label
|
||||
}
|
||||
|
||||
export function NumberInput({ label, value, onChange, min, max }: { label: string; value: number; onChange: (v: number) => void; min?: number; max?: number }) {
|
||||
const id = `numberinput-${label.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
return (
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">{label}</label>
|
||||
<label htmlFor={id} className="text-[10px] text-zinc-500 block mb-1">{label}</label>
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseInt(e.target.value, 10) || 0)}
|
||||
min={min}
|
||||
max={max}
|
||||
aria-label={label}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500 font-mono"
|
||||
/>
|
||||
</div>
|
||||
@ -89,19 +95,21 @@ export function Toggle({ label, checked, onChange }: { label: string; checked: b
|
||||
}
|
||||
|
||||
export function TagList({ label, values, onChange, placeholder }: { label: string; values: string[]; onChange: (v: string[]) => void; placeholder?: string }) {
|
||||
const id = `taglist-${label.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
const [input, setInput] = useState("");
|
||||
return (
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">{label}</label>
|
||||
<label htmlFor={id} className="text-[10px] text-zinc-500 block mb-1">{label}</label>
|
||||
<div className="flex flex-wrap gap-1 mb-1">
|
||||
{values.map((v, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-zinc-800 border border-zinc-700 rounded text-[10px] text-zinc-300 font-mono">
|
||||
{v}
|
||||
<button aria-label={`Remove tag ${v}`} onClick={() => onChange(values.filter((_, j) => j !== i))} className="text-zinc-500 hover:text-red-400">×</button>
|
||||
<button type="button" aria-label={`Remove tag ${v}`} onClick={() => onChange(values.filter((_, j) => j !== i))} className="text-zinc-500 hover:text-red-400">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
@ -112,6 +120,7 @@ export function TagList({ label, values, onChange, placeholder }: { label: strin
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder || "Type and press Enter"}
|
||||
aria-label={label}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-[10px] text-zinc-200 focus:outline-none focus:border-blue-500 font-mono"
|
||||
/>
|
||||
</div>
|
||||
@ -122,7 +131,7 @@ export function Section({ title, children, defaultOpen = true }: { title: string
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="border border-zinc-800 rounded mb-2">
|
||||
<button onClick={() => setOpen(!open)} className="w-full flex items-center justify-between px-3 py-1.5 text-[10px] text-zinc-400 hover:text-zinc-200 bg-zinc-900/50">
|
||||
<button type="button" onClick={() => setOpen(!open)} className="w-full flex items-center justify-between px-3 py-1.5 text-[10px] text-zinc-400 hover:text-zinc-200 bg-zinc-900/50">
|
||||
<span className="font-medium uppercase tracking-wider">{title}</span>
|
||||
<span>{open ? "▾" : "▸"}</span>
|
||||
</button>
|
||||
|
||||
@ -113,9 +113,9 @@ function SecretRow({ label, secretKey, isSet, scope, globalMode, onSave, onDelet
|
||||
{isSet && <span className="text-[10px] text-green-500 bg-green-900/30 px-1.5 py-0.5 rounded">Set</span>}
|
||||
{scope && <ScopeBadge scope={scope} />}
|
||||
{!editing && isSet && (globalMode || scope !== "global") && (
|
||||
<button onClick={onDelete} className="text-[11px] text-red-400 hover:text-red-300">Remove</button>
|
||||
<button type="button" onClick={onDelete} className="text-[11px] text-red-400 hover:text-red-300">Remove</button>
|
||||
)}
|
||||
<button onClick={() => setEditing(!editing)} className="text-[11px] text-blue-400 hover:text-blue-300">
|
||||
<button type="button" onClick={() => setEditing(!editing)} className="text-[11px] text-blue-400 hover:text-blue-300">
|
||||
{actionLabel()}
|
||||
</button>
|
||||
</div>
|
||||
@ -128,7 +128,7 @@ function SecretRow({ label, secretKey, isSet, scope, globalMode, onSave, onDelet
|
||||
type={isPlaintext ? "text" : "password"} autoFocus
|
||||
className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-2 py-1 text-[10px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => { onSave(value); setEditing(false); setValue(""); }}
|
||||
disabled={!value}
|
||||
className="px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white disabled:opacity-30"
|
||||
@ -165,10 +165,10 @@ function CustomSecretRow({ secretKey, scope, globalMode, onSave, onDelete }: {
|
||||
<span className="text-[10px] text-green-500">Set</span>
|
||||
{!globalMode && <ScopeBadge scope={scope} />}
|
||||
{canDelete && !editing && (
|
||||
<button onClick={onDelete} className="text-[11px] text-red-400 hover:text-red-300">Remove</button>
|
||||
<button type="button" onClick={onDelete} className="text-[11px] text-red-400 hover:text-red-300">Remove</button>
|
||||
)}
|
||||
{(canDelete || showOverride) && (
|
||||
<button onClick={() => setEditing(!editing)} className="text-[11px] text-blue-400 hover:text-blue-300">
|
||||
<button type="button" onClick={() => setEditing(!editing)} className="text-[11px] text-blue-400 hover:text-blue-300">
|
||||
{editing ? "Cancel" : showOverride ? "Override" : "Update"}
|
||||
</button>
|
||||
)}
|
||||
@ -181,7 +181,7 @@ function CustomSecretRow({ secretKey, scope, globalMode, onSave, onDelete }: {
|
||||
placeholder="New value" type="password" autoFocus
|
||||
className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-2 py-1 text-[10px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => { onSave(value); setEditing(false); setValue(""); }}
|
||||
disabled={!value}
|
||||
className="px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white disabled:opacity-30"
|
||||
@ -355,16 +355,16 @@ export function SecretsSection({ workspaceId, requiredEnv }: { workspaceId: stri
|
||||
<input value={newValue} onChange={(e) => setNewValue(e.target.value)} placeholder="Value" type="password"
|
||||
className="w-full bg-zinc-900 border border-zinc-600 rounded px-2 py-1 text-[10px] text-zinc-100 focus:outline-none focus:border-blue-500" />
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => { if (newKey && newValue) handleSave(newKey, newValue); }} disabled={!newKey || !newValue}
|
||||
<button type="button" onClick={() => { if (newKey && newValue) handleSave(newKey, newValue); }} disabled={!newKey || !newValue}
|
||||
className="px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white disabled:opacity-30">
|
||||
Save{globalMode ? " (Global)" : ""}
|
||||
</button>
|
||||
<button onClick={() => { setShowAdd(false); setNewKey(""); setNewValue(""); }}
|
||||
<button type="button" onClick={() => { setShowAdd(false); setNewKey(""); setNewValue(""); }}
|
||||
className="px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAdd(true)} className="text-[10px] text-blue-400 hover:text-blue-300">
|
||||
<button type="button" onClick={() => setShowAdd(true)} className="text-[10px] text-blue-400 hover:text-blue-300">
|
||||
+ Add {globalMode ? "Global " : ""}Variable
|
||||
</button>
|
||||
)}
|
||||
|
||||
@ -30,7 +30,7 @@ export function RevealToggle({
|
||||
|
||||
function EyeIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
@ -39,7 +39,7 @@ function EyeIcon() {
|
||||
|
||||
function EyeOffIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
|
||||
@ -55,8 +55,6 @@ describe("redirectToLogin", () => {
|
||||
},
|
||||
});
|
||||
redirectToLogin("sign-in");
|
||||
// href now holds the redirect target. encodeURIComponent(href) must
|
||||
// appear in the query.
|
||||
expect((window.location as unknown as { href: string }).href).toContain("/cp/auth/login");
|
||||
expect((window.location as unknown as { href: string }).href).toContain(
|
||||
encodeURIComponent(href),
|
||||
@ -76,4 +74,39 @@ describe("redirectToLogin", () => {
|
||||
redirectToLogin("sign-up");
|
||||
expect((window.location as unknown as { href: string }).href).toContain("/cp/auth/signup");
|
||||
});
|
||||
|
||||
// Regression: AuthGate + redirectToLogin mutual recursion on /cp/auth/login
|
||||
// caused double-encoded return_to that grew until the URL exceeded 431.
|
||||
// Guard: redirectToLogin must NOT set window.location when already on an
|
||||
// auth path, otherwise each call adds another encoding layer.
|
||||
it("does NOT set window.location when already on /cp/auth/login (redirect loop guard)", () => {
|
||||
const loginHref = "https://app.moleculesai.app/cp/auth/login?return_to=https%3A%2F%2Facme.moleculesai.app%2Fdashboard";
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: {
|
||||
href: loginHref,
|
||||
pathname: "/cp/auth/login",
|
||||
hostname: "app.moleculesai.app",
|
||||
protocol: "https:",
|
||||
},
|
||||
});
|
||||
redirectToLogin("sign-in");
|
||||
// href must be unchanged — any mutation means the guard is missing
|
||||
expect((window.location as unknown as { href: string }).href).toBe(loginHref);
|
||||
});
|
||||
|
||||
it("does NOT set window.location when already on /cp/auth/signup (redirect loop guard)", () => {
|
||||
const signupHref = "https://app.moleculesai.app/cp/auth/signup";
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: {
|
||||
href: signupHref,
|
||||
pathname: "/cp/auth/signup",
|
||||
hostname: "app.moleculesai.app",
|
||||
protocol: "https:",
|
||||
},
|
||||
});
|
||||
redirectToLogin("sign-up");
|
||||
expect((window.location as unknown as { href: string }).href).toBe(signupHref);
|
||||
});
|
||||
});
|
||||
|
||||
120
canvas/src/lib/runtimeProfiles.ts
Normal file
120
canvas/src/lib/runtimeProfiles.ts
Normal file
@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Runtime profiles — per-runtime UX metadata.
|
||||
*
|
||||
* Scaling target: hundreds of runtimes (plugin-architecture-v2 roadmap).
|
||||
* This module is the single source of truth for runtime-specific UI knobs
|
||||
* on the canvas side. Each runtime can declare:
|
||||
*
|
||||
* - provisionTimeoutMs: when to show the "taking longer than expected"
|
||||
* banner. Fast docker runtimes = 2min; slow source-build runtimes = 12min.
|
||||
* - (future) label, icon, color, helpUrl, capabilities — add as needed.
|
||||
*
|
||||
* Resolution order (most specific wins):
|
||||
*
|
||||
* 1. Server-provided override on the workspace data (e.g.
|
||||
* `workspace.data.provisionTimeoutMs` set from a template manifest).
|
||||
* Lets operators tune without a canvas release once server-side
|
||||
* declarative config lands.
|
||||
* 2. Per-runtime entry in RUNTIME_PROFILES.
|
||||
* 3. DEFAULT_RUNTIME_PROFILE.
|
||||
*
|
||||
* Adding a new runtime:
|
||||
* - If it's fast (≤ 2min cold boot): do nothing, the default catches it.
|
||||
* - If it's slow: add one entry to RUNTIME_PROFILES below.
|
||||
* - Long-term: move runtime profiles server-side so this file can shrink.
|
||||
*
|
||||
* Architectural note: this deliberately lives under /lib, NOT
|
||||
* /components/ProvisioningTimeout. Other components (e.g. a
|
||||
* "create workspace" dialog that needs to know the runtime's expected
|
||||
* cold-boot time) should import from here too — avoids duplicating the
|
||||
* runtime-name knowledge across the codebase.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Structural shape of a runtime profile. Add fields as new UX knobs
|
||||
* become runtime-specific. Every field should be optional so new runtimes
|
||||
* can partially fill the profile without breaking older code that reads
|
||||
* only some fields.
|
||||
*/
|
||||
export interface RuntimeProfile {
|
||||
/** Milliseconds before the canvas shows the "taking too long" banner.
|
||||
* Base value — the ProvisioningTimeout component still scales this by
|
||||
* concurrent-provisioning count. */
|
||||
provisionTimeoutMs?: number;
|
||||
// Future extensions (kept commented until used):
|
||||
// label?: string;
|
||||
// icon?: string;
|
||||
// color?: string;
|
||||
// helpUrl?: string;
|
||||
}
|
||||
|
||||
/** The floor every runtime inherits unless it overrides. Calibrated for
|
||||
* docker-local fast runtimes (claude-code, langgraph, crewai) where cold
|
||||
* boot is 30-90s. */
|
||||
export const DEFAULT_RUNTIME_PROFILE: Required<
|
||||
Pick<RuntimeProfile, "provisionTimeoutMs">
|
||||
> = {
|
||||
provisionTimeoutMs: 120_000, // 2 min
|
||||
};
|
||||
|
||||
/**
|
||||
* Named per-runtime overrides. Keep this map small and explicit —
|
||||
* each entry is a deliberate statement that this runtime's cold-boot
|
||||
* behavior differs materially from the default.
|
||||
*
|
||||
* Each override must also ship with a comment explaining WHY the default
|
||||
* is wrong for this runtime. Unexplained numbers rot.
|
||||
*/
|
||||
export const RUNTIME_PROFILES: Record<string, RuntimeProfile> = {
|
||||
hermes: {
|
||||
// 12 min. Installs ripgrep + ffmpeg + node22 + builds hermes-agent
|
||||
// from source + Playwright + Chromium (~300MB download). Measured
|
||||
// cold boots on staging EC2 routinely land at 8-13 min. Aligns
|
||||
// with SaaS E2E's PROVISION_TIMEOUT_SECS=900 (15 min) so the UI
|
||||
// warning lands shortly before the backend itself gives up.
|
||||
provisionTimeoutMs: 720_000,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Data fields the canvas can consult for per-workspace overrides. These
|
||||
* let the backend (via workspace data on the socket payload) override
|
||||
* profile values without a canvas release.
|
||||
*
|
||||
* Intentionally loose typing — if a field isn't present on the node, we
|
||||
* fall through to the runtime profile.
|
||||
*/
|
||||
export interface WorkspaceRuntimeOverrides {
|
||||
provisionTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a runtime profile for a given runtime name, optionally merging
|
||||
* server-provided per-workspace overrides on top.
|
||||
*
|
||||
* Resolution (most-specific wins):
|
||||
* overrides.provisionTimeoutMs
|
||||
* → RUNTIME_PROFILES[runtime].provisionTimeoutMs
|
||||
* → DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs
|
||||
*/
|
||||
export function getRuntimeProfile(
|
||||
runtime: string | undefined,
|
||||
overrides?: WorkspaceRuntimeOverrides,
|
||||
): Required<Pick<RuntimeProfile, "provisionTimeoutMs">> {
|
||||
const profile = runtime ? RUNTIME_PROFILES[runtime] : undefined;
|
||||
return {
|
||||
provisionTimeoutMs:
|
||||
overrides?.provisionTimeoutMs ??
|
||||
profile?.provisionTimeoutMs ??
|
||||
DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convenience: just the provisionTimeoutMs. Equivalent to
|
||||
* `getRuntimeProfile(runtime, overrides).provisionTimeoutMs`. */
|
||||
export function provisionTimeoutForRuntime(
|
||||
runtime: string | undefined,
|
||||
overrides?: WorkspaceRuntimeOverrides,
|
||||
): number {
|
||||
return getRuntimeProfile(runtime, overrides).provisionTimeoutMs;
|
||||
}
|
||||
@ -5,27 +5,34 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
global.fetch = vi.fn();
|
||||
|
||||
import { useCanvasStore } from "../canvas";
|
||||
import type { WorkspaceData } from "../socket";
|
||||
import type { WorkspaceNodeData } from "../canvas";
|
||||
|
||||
function makeWS(overrides: Partial<WorkspaceData> & { id: string }): WorkspaceData {
|
||||
function makeWS(
|
||||
overrides: Partial<WorkspaceNodeData> & { id: string },
|
||||
): WorkspaceNodeData {
|
||||
// makeWS builds a minimal WorkspaceNodeData for tests that set state
|
||||
// directly on the store (bypassing hydrate). The `id` override is
|
||||
// ignored — node IDs live on the outer Node<> wrapper, not inside
|
||||
// `data`. It's accepted here so callers can keep their existing
|
||||
// `makeWS({ id: "ws-foo" })` call sites even though the id is only
|
||||
// used on the Node<> wrapper at the call site.
|
||||
void overrides.id;
|
||||
return {
|
||||
name: "WS",
|
||||
role: "agent",
|
||||
tier: 1,
|
||||
status: "online",
|
||||
agent_card: null,
|
||||
agentCard: null,
|
||||
url: "http://localhost:9000",
|
||||
parent_id: null,
|
||||
active_tasks: 0,
|
||||
last_error_rate: 0,
|
||||
last_sample_error: "",
|
||||
uptime_seconds: 60,
|
||||
current_task: "",
|
||||
x: 0,
|
||||
y: 0,
|
||||
parentId: null,
|
||||
activeTasks: 0,
|
||||
lastErrorRate: 0,
|
||||
lastSampleError: "",
|
||||
currentTask: "",
|
||||
collapsed: false,
|
||||
runtime: "",
|
||||
budget_limit: null,
|
||||
needsRestart: false,
|
||||
budgetLimit: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@ -148,13 +155,13 @@ describe("batchRestart — partial failure", () => {
|
||||
id: "ws-ok",
|
||||
type: "workspace",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...makeWS({ id: "ws-ok" }), needsRestart: true } as WorkspaceData & { needsRestart: boolean },
|
||||
data: { ...makeWS({ id: "ws-ok" }), needsRestart: true } as WorkspaceNodeData,
|
||||
},
|
||||
{
|
||||
id: "ws-fail",
|
||||
type: "workspace",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...makeWS({ id: "ws-fail" }), needsRestart: true } as WorkspaceData & { needsRestart: boolean },
|
||||
data: { ...makeWS({ id: "ws-fail" }), needsRestart: true } as WorkspaceNodeData,
|
||||
},
|
||||
],
|
||||
selectedNodeIds: new Set(["ws-ok", "ws-fail"]),
|
||||
@ -166,7 +173,7 @@ describe("batchRestart — partial failure", () => {
|
||||
});
|
||||
|
||||
const byId = Object.fromEntries(
|
||||
useCanvasStore.getState().nodes.map((n) => [n.id, n.data as WorkspaceData & { needsRestart?: boolean }])
|
||||
useCanvasStore.getState().nodes.map((n) => [n.id, n.data as WorkspaceNodeData])
|
||||
);
|
||||
expect(byId["ws-ok"].needsRestart).toBe(false);
|
||||
expect(byId["ws-fail"].needsRestart).toBe(true);
|
||||
@ -179,7 +186,7 @@ describe("batchRestart — partial failure", () => {
|
||||
id: "ws-fail",
|
||||
type: "workspace",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...makeWS({ id: "ws-fail" }), needsRestart: true } as WorkspaceData & { needsRestart: boolean },
|
||||
data: { ...makeWS({ id: "ws-fail" }), needsRestart: true } as WorkspaceNodeData,
|
||||
},
|
||||
],
|
||||
selectedNodeIds: new Set(["ws-fail"]),
|
||||
|
||||
BIN
docs/assets/blog/2026-04-20-mcp-server-list/og.png
Normal file
BIN
docs/assets/blog/2026-04-20-mcp-server-list/og.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
354
docs/blog/2026-04-20-mcp-server-list/index.md
Normal file
354
docs/blog/2026-04-20-mcp-server-list/index.md
Normal file
@ -0,0 +1,354 @@
|
||||
---
|
||||
title: "The MCP Server List: Which Servers Work With Molecule AI?"
|
||||
date: 2026-04-20
|
||||
slug: mcp-server-list
|
||||
description: "A practical guide to the Model Context Protocol ecosystem — finding the right MCP server for your use case, which ones integrate with Molecule AI, and how to evaluate servers before you commit."
|
||||
tags: [mcp, model-context-protocol, ai-agents, integrations]
|
||||
author: Molecule AI
|
||||
og_title: "The MCP Server List: Which Servers Work With Molecule AI?"
|
||||
og_description: "Find the right MCP server for your AI agent workflow. Full list of reference servers, official integrations, server frameworks, and community registries — with Molecule AI compatibility notes."
|
||||
og_image: /assets/blog/2026-04-20-mcp-server-list/og.png
|
||||
twitter_card: summary_large_image
|
||||
canonical: https://molecule.ai/blog/mcp-server-list
|
||||
keywords:
|
||||
- MCP server list
|
||||
- MCP servers
|
||||
- Model Context Protocol
|
||||
- MCP server
|
||||
- MCP integration
|
||||
---
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": "The MCP Server List: Which Servers Work With Molecule AI?",
|
||||
"datePublished": "2026-04-20",
|
||||
"dateModified": "2026-04-21",
|
||||
"author": { "@type": "Organization", "name": "Molecule AI" },
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "Molecule AI",
|
||||
"logo": { "@type": "ImageObject", "url": "https://molecule.ai/logo.png" }
|
||||
},
|
||||
"description": "A practical guide to the Model Context Protocol ecosystem — finding the right MCP server for your use case, which ones integrate with Molecule AI, and how to evaluate servers before you commit.",
|
||||
"keywords": "MCP server list, MCP servers, Model Context Protocol, MCP server, MCP integration",
|
||||
"url": "https://molecule.ai/blog/mcp-server-list"
|
||||
}
|
||||
</script>
|
||||
|
||||
# The MCP Server List: Which Servers Work With Molecule AI?
|
||||
|
||||
The [Model Context Protocol](/docs/guides/mcp-server-setup) (MCP) is an open standard that lets AI agents connect to external tools and data sources through a unified interface. Rather than writing custom code for every tool integration, MCP servers expose resources and tools that any compatible AI agent can discover and call. This MCP server list covers everything you need to find the right integration for your workflow.
|
||||
|
||||
Molecule AI supports MCP out of the box. This means any MCP server in the ecosystem — from lightweight reference implementations to enterprise-grade integrations — can be added to a Molecule AI agent with a server configuration. No forks, no wrappers, no compatibility layers required. This page is your practical MCP server list for real-world AI agent workflows.
|
||||
|
||||
This guide covers the full MCP server list that matters: reference servers from the MCP spec authors, official integrations from major vendors, server frameworks for building your own, and community-maintained registries where the broader MCP ecosystem publishes new MCP servers every week. Whether you need one MCP server or a stack of them, this MCP server list gives you the starting points for every major category.
|
||||
|
||||
---
|
||||
|
||||
## What Is an MCP Server?
|
||||
|
||||
An MCP server is a process that implements the Model Context Protocol. It runs separately from your AI agent and communicates over stdio or HTTP+SSE. When a compatible AI agent connects, it receives a manifest of available **tools**, **resources**, and **prompts** — no code changes on the agent side.
|
||||
|
||||
The MCP specification defines the transport layer and message shapes. The server implementer decides what capabilities to expose. This separation is what makes the MCP ecosystem portable: an MCP server written for one MCP-compatible platform works on any other, including Molecule AI.
|
||||
|
||||
The key MCP concepts that every server implements:
|
||||
|
||||
- **Tools** — functions the agent can call (e.g., `search_code`, `read_file`)
|
||||
- **Resources** — data the agent can read (e.g., repository contents, database schemas)
|
||||
- **Prompts** — reusable prompt templates the agent can load
|
||||
|
||||
Every MCP server in this list exposes at least one of these three primitives. Most expose tools; a well-designed MCP server also exposes resources. The Model Context Protocol makes all of this possible by providing a shared vocabulary and transport — so MCP servers and the agents that call them don't need to coordinate on anything beyond the protocol itself.
|
||||
|
||||
---
|
||||
|
||||
## MCP Reference Servers
|
||||
|
||||
The [modelcontextprotocol GitHub organization](https://github.com/modelcontextprotocol) maintains a set of reference server implementations. These are canonical examples maintained by the MCP spec authors and are often the best starting point for common integrations. This reference MCP server list is kept up to date with each protocol release.
|
||||
|
||||
### Filesystem MCP Server
|
||||
|
||||
Provides local file system access. Useful for AI agents that need to read project files, write output, or navigate a codebase.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/directory"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Molecule AI note:** Configure `allowedDirectories` in the server args to scope filesystem access. Use separate server configs per workspace if you need per-project isolation.
|
||||
|
||||
### Git MCP Server
|
||||
|
||||
Exposes Git operations — commit history, diffs, branch listings, file contents at any ref. Useful for AI agents doing code review or changelog generation.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"git": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-git", "--repository", "/path/to/repo"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Molecule AI note:** Pass the `--repository` flag to scope the server to a specific project. Without it, the server operates on whatever directory the process runs in.
|
||||
|
||||
### Memory MCP Server
|
||||
|
||||
A vector-backed memory server that persists facts across agent sessions using embeddings. The agent can store key-value facts and retrieve them semantically later.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"memory": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/memory-server/dist/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Molecule AI note:** Combine with Molecule AI's built-in session context for hybrid short-term + long-term memory strategies.
|
||||
|
||||
### Brave Search MCP Server
|
||||
|
||||
Web search via the Brave Search API. Gives the agent real-time internet access for research tasks.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"brave-search": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
|
||||
"env": {
|
||||
"BRAVE_API_KEY": "your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Molecule AI note:** Set the `BRAVE_API_KEY` as an environment variable in your Molecule AI workspace secrets, not in the server config file.
|
||||
|
||||
---
|
||||
|
||||
## Official MCP Integrations
|
||||
|
||||
Beyond the reference servers, several established products ship MCP-compatible servers. These are production-grade implementations maintained by the vendors. Each MCP integration in this section ships with vendor support and backward-compatibility guarantees.
|
||||
|
||||
### Slack MCP Integration
|
||||
|
||||
The official Slack SDK includes an MCP server that exposes channels, messages, and thread replies as tools and resources. An agent can post updates, read channel history, or monitor for specific events.
|
||||
|
||||
**Use cases:** Team status updates, incident channel posting, cross-team workflow automation.
|
||||
|
||||
### GitHub MCP Integration
|
||||
|
||||
The GitHub MCP server surfaces repositories, issues, pull requests, and discussions as structured resources. Agents can create issues, comment on PRs, or query code search.
|
||||
|
||||
**Use cases:** Automated code review summaries, issue triaging, release note generation.
|
||||
|
||||
### AWS KB Retrieval MCP Integration
|
||||
|
||||
Amazon's Bedrock Knowledge Bases can be accessed via MCP. Gives agents read access to indexed enterprise documents.
|
||||
|
||||
**Use cases:** Internal knowledge base queries, policy document retrieval, compliance checking.
|
||||
|
||||
### Google Drive MCP Integration
|
||||
|
||||
Read access to Google Drive files and folders. Agents can search documents, read sheet data, or pull slide content.
|
||||
|
||||
**Use cases:** Research synthesis from Drive documents, automated reporting from Sheets.
|
||||
|
||||
---
|
||||
|
||||
## MCP Server Frameworks
|
||||
|
||||
If you need a custom MCP integration not covered by existing servers, MCP server frameworks let you build one without implementing the Model Context Protocol from scratch. These frameworks handle the protocol boilerplate so you can focus on your tool's logic. Building your own MCP server is the right call when you have a proprietary data source, an internal API, or a domain-specific tool that isn't covered by the available MCP servers in the ecosystem.
|
||||
|
||||
### Python MCP SDK
|
||||
|
||||
The official Python implementation. Ideal for data-heavy or ML-adjacent integrations.
|
||||
|
||||
```python
|
||||
from mcp.server import Server
|
||||
from mcp.types import Tool, TextContent
|
||||
|
||||
server = Server("my-analytics-server")
|
||||
|
||||
@server.list_tools()
|
||||
async def list_tools():
|
||||
return [
|
||||
Tool(
|
||||
name="query_analytics",
|
||||
description="Run a query against the analytics database",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sql": {"type": "string", "description": "SQL query to execute"}
|
||||
},
|
||||
"required": ["sql"]
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
@server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict):
|
||||
if name == "query_analytics":
|
||||
result = run_query(arguments["sql"])
|
||||
return [TextContent(type="text", text=str(result))]
|
||||
```
|
||||
|
||||
**Molecule AI note:** Package your server as a Docker image and reference it by image URL in your Molecule AI workspace server config for one-command deployment.
|
||||
|
||||
### TypeScript MCP SDK
|
||||
|
||||
The official Node.js/TypeScript implementation. Best for web-service integrations, API wrappers, and real-time data sources.
|
||||
|
||||
### Go MCP Server Framework
|
||||
|
||||
A lightweight Go implementation for high-performance or infrastructure-level integrations.
|
||||
|
||||
---
|
||||
|
||||
## Community MCP Registries
|
||||
|
||||
The MCP ecosystem grows through community contributions. These registries index servers by category and are the best places to discover new MCP servers without searching GitHub manually. Bookmark these — the community publishes new MCP servers every week, and these registries stay current.
|
||||
|
||||
### awesome-mcp
|
||||
|
||||
The canonical community MCP server list. Maintained on GitHub with categorized entries for tools, resources, and prompt servers. Covers everything from production-grade MCP servers to experimental community projects. Start here when you know the category you need but not the specific MCP server.
|
||||
|
||||
### Model Context Protocol Registry (registry.mcp.so)
|
||||
|
||||
A structured registry that categorizes servers by domain: development, productivity, data, infrastructure. Each entry links to the implementation and documents supported MCP features.
|
||||
|
||||
### MCP Hub
|
||||
|
||||
A community-curated directory with install commands for each server. Particularly useful for quickly spinning up a new MCP server via `npx` or `uvx`.
|
||||
|
||||
---
|
||||
|
||||
## How to Install an MCP Server
|
||||
|
||||
The exact install steps depend on the server, but most MCP servers follow the same startup patterns. Most servers can be started with a single command:
|
||||
|
||||
```bash
|
||||
# Via npx (Node.js servers)
|
||||
npx -y @modelcontextprotocol/server-filesystem /allowed/path
|
||||
|
||||
# Via uvx (Python servers)
|
||||
uvx mcp-server-git --repository /path/to/repo
|
||||
|
||||
# Via Docker (any server, in an isolated container)
|
||||
docker run -v /data:/data my-registry/my-mcp-server --allowed-path /data
|
||||
```
|
||||
|
||||
Once started, add the MCP server to your Molecule AI workspace configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "docker",
|
||||
"args": ["run", "--rm", "-v", "/data:/data", "my-registry/my-mcp-server", "--allowed-path", "/data"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Molecule AI note:** Use Docker-based servers for any MCP integration that requires credentials or filesystem access you don't want to co-locate with the agent process. Molecule AI's workspace isolation handles the container lifecycle automatically.
|
||||
|
||||
---
|
||||
|
||||
## Choosing the Right MCP Server: A Decision Guide
|
||||
|
||||
Not every MCP server belongs in every project. Here's how to evaluate which MCP servers to add to your Molecule AI workspace. The best MCP server is the one that does exactly what your agent needs — nothing more. Extra MCP servers add latency, credential surface, and maintenance burden without adding value.
|
||||
|
||||
| Need | Recommended MCP servers |
|
||||
|------|------------------------|
|
||||
| Read project files | filesystem |
|
||||
| Git operations | git |
|
||||
| Web search | brave-search |
|
||||
| Slack/Teams integration | slack, teams |
|
||||
| Cloud infrastructure queries | aws-kb, google-drive |
|
||||
| Long-term memory | memory |
|
||||
| Custom data source | Build with Python/TypeScript SDK |
|
||||
|
||||
**Start narrow.** Add MCP servers as your agent's tasks require them. Each MCP server is a new attack surface and a new failure mode. The Model Context Protocol gives you a consistent interface to manage them all — but you still need to evaluate each MCP server's security posture before adding it to a workspace. Molecule AI's workspace-level server configuration makes it easy to add servers incrementally and revoke access at the workspace boundary.
|
||||
|
||||
---
|
||||
|
||||
## MCP Server Governance With Molecule AI
|
||||
|
||||
Every MCP server your agent can access is a decision about what the agent is permitted to do. Molecule AI gives you controls at the workspace level so you can govern your MCP servers in production:
|
||||
|
||||
- **Server allowlisting** — configure exactly which servers can run in a workspace
|
||||
- **Environment variable scoping** — API keys used by MCP servers stay in workspace secrets, not in config files
|
||||
- **Audit logging** — every tool call made through an MCP server is recorded in the workspace activity log
|
||||
- **Workspace isolation** — each workspace runs its server config independently, so one team's servers don't affect another's
|
||||
|
||||
This is the governance layer that makes running MCP servers practical in production. A list of MCP servers is only as useful as the controls around them. Molecule AI provides those controls built in.
|
||||
|
||||
Get started with MCP on Molecule AI in the [MCP Server Setup Guide](/docs/guides/mcp-server-setup).
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "What is an MCP server?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "An MCP server is a process that implements the Model Context Protocol (MCP). It runs separately from your AI agent and exposes tools, resources, and prompts that any MCP-compatible AI agent can discover and call — without custom code on the agent side."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "How do I add an MCP server to Molecule AI?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Add the server configuration to your Molecule AI workspace config under the mcpServers key. Most servers can be started with a single command (npx, uvx, or Docker) and then referenced in your workspace configuration. Molecule AI's workspace isolation handles the container lifecycle automatically."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Which MCP servers are officially supported?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Molecule AI supports the full MCP ecosystem. Reference servers (filesystem, git, memory, brave-search) are maintained by the modelcontextprotocol GitHub organization. Official integrations from Slack, GitHub, AWS, and Google are also available. Any MCP server in the ecosystem is compatible with Molecule AI."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "How do I evaluate an MCP server before adding it to my agent?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Start narrow — add MCP servers only when your agent's tasks require them. Evaluate each server's security posture, credential requirements, and failure modes before adding it. Molecule AI's workspace-level server configuration makes it easy to add servers incrementally and revoke access at the workspace boundary."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Can I build a custom MCP server?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Yes. MCP server frameworks in Python (official), TypeScript, and Go let you build custom integrations without implementing the protocol from scratch. Package your server as a Docker image and reference it by image URL in your Molecule AI workspace server config for one-command deployment."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
---
|
||||
|
||||
*To stay current with the MCP ecosystem, watch the [modelcontextprotocol GitHub organization](https://github.com/modelcontextprotocol) for new server releases and protocol updates. This MCP server list is updated as the ecosystem evolves.*
|
||||
43
docs/blog/2026-04-30-phase-34/index.md
Normal file
43
docs/blog/2026-04-30-phase-34/index.md
Normal file
@ -0,0 +1,43 @@
|
||||
---
|
||||
title: "What's New in Phase 34"
|
||||
date: "2026-04-30"
|
||||
slug: "whats-new-phase-34"
|
||||
description: "Phase 34 ships Partner API Keys, SaaS Federation v2, Tool Trace observability, and Platform Instructions governance. Four production features for AI agent platforms."
|
||||
tags: [phase-34, changelog, partner-api, federation, tool-trace, platform-instructions]
|
||||
---
|
||||
|
||||
# What's New in Phase 34
|
||||
|
||||
**April 30, 2026**
|
||||
|
||||
Phase 34 ships four platform features: Partner API Keys (GA), SaaS Federation v2 (GA), Tool Trace observability (GA), and Platform Instructions governance (GA).
|
||||
|
||||
---
|
||||
|
||||
## Partner API Keys (GA)
|
||||
|
||||
Marketplace resellers, CI/CD tooling, and automation platforms can now manage Molecule AI orgs programmatically using scoped, rate-limited, revocable `mol_pk_` API keys. Create orgs, provision workspaces, and revoke access — no browser session required.
|
||||
|
||||
Available scopes: `orgs:create`, `orgs:list`, `orgs:delete`, `workspaces:create`, `billing:read`. Pricing tiers [coming soon](https://molecule.ai/pricing). Available on Partner and Enterprise plans.
|
||||
|
||||
## SaaS Federation v2 (GA)
|
||||
|
||||
Organizations can now collaborate across org boundaries using a structured, auditable trust model. Grant named federation trusts to partner orgs — scoped to specific workspaces and operations — without sharing credentials. Trust relationships are revocable at any time.
|
||||
|
||||
Federation v2 uses A2A protocol with embedded federation claims, so cross-org agent handoffs are verifiable at the receiving end. Available on Enterprise plans.
|
||||
|
||||
## Tool Trace Observability (GA)
|
||||
|
||||
Every A2A response now includes a structured `tool_trace` array in `Message.metadata`. Each entry records the tool called, its inputs, an output preview, and a `run_id` that correctly groups concurrent parallel calls.
|
||||
|
||||
Tool Trace is available on all plans. No sidecar service. No sampling. See the [A2A Protocol Reference](/docs/api-protocol/a2a-protocol) for the full schema.
|
||||
|
||||
## Platform Instructions Governance (GA)
|
||||
|
||||
Policy rules can now be prepended to an agent's system prompt at workspace startup — at the source, before the first token is generated. Rules are scoped to `global` (all workspaces in the org) or `workspace` (a specific workspace only). The `wsAuth`-gated resolve endpoint ensures workspaces can only read their own instructions.
|
||||
|
||||
Platform Instructions are available on Enterprise plans. Each instruction is capped at 8KB. Resolved instruction sets are cached at startup to avoid per-turn latency.
|
||||
|
||||
---
|
||||
|
||||
For full details, see the [Phase 34 launch post](/blog/phase-34-launch).
|
||||
173
docs/blog/a2a-v1-production-reference-2026-04-24.md
Normal file
173
docs/blog/a2a-v1-production-reference-2026-04-24.md
Normal file
@ -0,0 +1,173 @@
|
||||
---
|
||||
title: "Running A2A v1.0 in Production: What the Migration Actually Looks Like"
|
||||
description: "We migrated Molecule AI's entire agent fleet from a2a-sdk 0.3.x to v1.0 last week. Here's the real diff — four breaking changes, six files, eight smoke scenarios — and what we learned running A2A at scale before most teams have started."
|
||||
date: 2026-04-24
|
||||
slug: a2a-v1-production-reference
|
||||
tags: [a2a, sdk, migration, production, multi-agent, protocol]
|
||||
keywords: [A2A v1.0 migration, a2a-sdk production, multi-agent protocol, agent fleet, A2A breaking changes, agent SDK upgrade]
|
||||
canonical: https://docs.molecule.ai/blog/a2a-v1-production-reference
|
||||
---
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": "Running A2A v1.0 in Production: What the Migration Actually Looks Like",
|
||||
"description": "We migrated Molecule AI's entire agent fleet from a2a-sdk 0.3.x to v1.0. Here's the real diff — four breaking changes, six files, eight smoke scenarios — and what we learned.",
|
||||
"author": { "@type": "Organization", "name": "Molecule AI" },
|
||||
"datePublished": "2026-04-24",
|
||||
"publisher": { "@type": "Organization", "name": "Molecule AI", "logo": { "@type": "ImageObject", "url": "https://molecule.ai/logo.png" } }
|
||||
}
|
||||
</script>
|
||||
|
||||
# Running A2A v1.0 in Production: What the Migration Actually Looks Like
|
||||
|
||||
Most organizations writing about A2A v1.0 are writing about what it *will* enable. We're writing about what it *required* — because we've been running it in production since before the Linux Foundation ratified it, and we just completed the migration from `a2a-sdk` 0.3.x to 1.0.0 across our full agent fleet.
|
||||
|
||||
This is a practitioner post. No pitch, no benchmark theater. Here's the real diff: four breaking changes, six files, eight smoke test scenarios, and what we learned.
|
||||
|
||||
---
|
||||
|
||||
## The Context: A2A at Scale in a Production Fleet
|
||||
|
||||
Molecule AI is a multi-agent orchestration platform. Every capability in the product — PM, Dev, Research, Marketing — runs as a discrete A2A-speaking workspace. The platform itself is the coordination layer: task delegation, inter-agent communication, fleet health, and audit attribution all route through the A2A protocol.
|
||||
|
||||
The fleet is always-on. Agents wake, accept delegations, complete tasks, and go idle — continuously. At peak, we're running concurrent delegations across six to eight active workspaces per session, with each workspace capable of spawning sub-delegations to sibling agents. The a2a-sdk sits at the center of this: every task dispatch, every `delegate_task` call, every heartbeat touches it.
|
||||
|
||||
When `a2a-sdk` 1.0.0 shipped with breaking changes, we had no option to defer. With Phase 34 GA targeting April 30, the migration needed to land on `staging` before April 25 — giving us a five-day buffer to validate before launch.
|
||||
|
||||
---
|
||||
|
||||
## What Changed in a2a-sdk 1.0.0
|
||||
|
||||
The SDK's breaking changes were intentional improvements, not incidental rewrites. Here's what actually moved.
|
||||
|
||||
### 1. Server bootstrap: `A2AStarletteApplication` is gone
|
||||
|
||||
In 0.3.x, you bootstrapped an A2A server with a single class:
|
||||
|
||||
```python
|
||||
# 0.3.x
|
||||
from a2a.server.apps import A2AStarletteApplication
|
||||
app = A2AStarletteApplication(agent_executor=executor, agent_card=card)
|
||||
```
|
||||
|
||||
In 1.0.0, this class was replaced by a Starlette route factory pattern. The `AgentCard` schema also changed — `capabilities` moved from a flat list to a structured object with typed fields:
|
||||
|
||||
```python
|
||||
# 1.0.0
|
||||
from a2a.server.apps import create_a2a_app
|
||||
from a2a.types import AgentCard, AgentCapabilities
|
||||
|
||||
card = AgentCard(
|
||||
name="my-agent",
|
||||
capabilities=AgentCapabilities(streaming=True, push_notifications=False)
|
||||
)
|
||||
app = create_a2a_app(agent_executor=executor, agent_card=card)
|
||||
```
|
||||
|
||||
Why it's better: the factory pattern makes it easier to compose A2A apps into larger ASGI trees — useful if your agent also serves a health check endpoint or a management API on the same process.
|
||||
|
||||
### 2. Part construction: positional constructor removed
|
||||
|
||||
In 0.3.x, you could pass a `TextPart` positionally:
|
||||
|
||||
```python
|
||||
# 0.3.x
|
||||
from a2a.types import Part, TextPart
|
||||
part = Part(TextPart(text="hello"))
|
||||
```
|
||||
|
||||
In 1.0.0, `Part` uses keyword arguments only:
|
||||
|
||||
```python
|
||||
# 1.0.0
|
||||
part = Part(text="hello")
|
||||
```
|
||||
|
||||
This is a clean-up: the positional form was ambiguous when `Part` was extended to support `data` and `file` variants. The keyword form is unambiguous and IDE-friendly.
|
||||
|
||||
### 3. `TaskState` enum: string constant replaced
|
||||
|
||||
```python
|
||||
# 0.3.x
|
||||
from a2a.types import TaskState
|
||||
state = TaskState.canceled
|
||||
|
||||
# 1.0.0
|
||||
from a2a.types import TASK_STATE_CANCELED
|
||||
state = TASK_STATE_CANCELED
|
||||
```
|
||||
|
||||
The shift from enum member to module-level constant is a minor ergonomic change that aligns with how other A2A state constants are referenced across the SDK. The actual string value is unchanged — this is a rename, not a semantic change.
|
||||
|
||||
### 4. `a2a.utils` → `a2a.helpers`
|
||||
|
||||
```python
|
||||
# 0.3.x
|
||||
from a2a.utils import build_text_artifact
|
||||
|
||||
# 1.0.0
|
||||
from a2a.helpers import build_text_artifact
|
||||
```
|
||||
|
||||
Module rename. The function signatures are identical; only the import path changed.
|
||||
|
||||
---
|
||||
|
||||
## The Migration: Six Files, One PR
|
||||
|
||||
All four breaking changes were contained in six files:
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `workspace/main.py` | `A2AStarletteApplication` → route factory + `AgentCard` restructure |
|
||||
| `workspace/a2a_executor.py` | `Part(TextPart(...))` → `Part(text=...)`, `TaskState.canceled` → `TASK_STATE_CANCELED`, `a2a.utils` → `a2a.helpers` |
|
||||
| `workspace/hermes_executor.py` | Enum rename + helpers import |
|
||||
| `workspace/google-adk/adapter.py` | Enum rename + helpers import |
|
||||
| `workspace/cli_executor.py` | `a2a.utils` → `a2a.helpers` |
|
||||
| `workspace/tests/conftest.py` | Mock stub updated to `a2a.helpers` |
|
||||
|
||||
Total: one PR (`fix/a2a-sdk-v1-migration`), merged to `staging` as commit `35bcad92`. No test failures. No behavior regression.
|
||||
|
||||
The migration was deliberately narrow — touching only the A2A bootstrap, part construction, enum references, and import paths. We made no structural changes to executor logic, task handling, or delegation routing in the same PR. This is the right call for a breaking-change migration: keep the semantic diff minimal so any regression is immediately attributable to the SDK change, not to coincidental refactoring.
|
||||
|
||||
---
|
||||
|
||||
## Validating the Migration: Eight Smoke Scenarios
|
||||
|
||||
Before merging, we ran eight smoke scenarios (S-1 through S-8) designed to exercise each layer of the A2A stack under the new SDK:
|
||||
|
||||
- **S-1 — Server starts and card is discoverable:** `GET /agent-card` returns a valid `AgentCard` with typed capabilities.
|
||||
- **S-2 — Task submission accepted:** `POST /tasks` with a `TextPart` payload returns a `202 Accepted` with a task ID.
|
||||
- **S-3 — Task state transitions:** Task progresses through `submitted → working → completed` without state machine errors.
|
||||
- **S-4 — Canceled task handling:** Cancellation request sets `TASK_STATE_CANCELED` correctly and is reflected in the task status response.
|
||||
- **S-5 — Helpers import resolves:** `build_text_artifact` and related helpers resolve from `a2a.helpers` with no `ImportError`.
|
||||
- **S-6 — Part keyword construction:** `Part(text=...)` constructs cleanly; `Part(data=...)` also resolves for binary payloads.
|
||||
- **S-7 — Delegation round-trip:** Full `delegate_task` cycle from a peer workspace completes end-to-end through the upgraded executor.
|
||||
- **S-8 — Concurrent delegation under load:** Five concurrent delegations across two workspaces complete without race conditions or dropped tasks.
|
||||
|
||||
All eight passed on `staging` before the PR merge. S-7 and S-8 are the high-value tests — they're the ones that would catch a regression in the bootstrap or part construction that only surfaces under real inter-agent traffic.
|
||||
|
||||
---
|
||||
|
||||
## What We'd Do Differently
|
||||
|
||||
**Pin the SDK version explicitly in every executor.**
|
||||
We found two executor files where `a2a-sdk` was listed as a loose dependency (`>=0.3.0`). When 1.0.0 shipped with breaking changes, those executors silently picked up the new version on the next `pip install`. For a library with a breaking change boundary at 1.0, lock the version (`==0.3.x` before migration; `==1.0.0` after) and treat the upgrade as a deliberate event, not a passive update.
|
||||
|
||||
**Test the `AgentCard` schema change separately.**
|
||||
The `A2AStarletteApplication` removal and the `AgentCard` restructure should have been two separate test cases. We caught the `AgentCard` schema issue during S-1 — but it would have been cleaner as an explicit pre-migration test rather than a discovery during smoke.
|
||||
|
||||
**Migrate mock stubs at the same time as production code.**
|
||||
`workspace/tests/conftest.py` was the last file we touched because it wasn't an executor. But stubs that patch `a2a.utils` will throw `ModuleNotFoundError` the moment the production code migrates to `a2a.helpers` and tests run. Update stubs in the same PR, same commit, as the production migration.
|
||||
|
||||
---
|
||||
|
||||
## What's Next
|
||||
|
||||
The `staging` branch is now on `a2a-sdk` 1.0.0. The `main` branch still carries the 0.3.x code — a `staging→main` sync PR is in progress to land the migration on `main` before Phase 34 GA on April 30.
|
||||
|
||||
If you're running `a2a-sdk` 0.3.x and planning the 1.0.0 migration, this post is the reference. The four breaking changes are well-contained, the migration is a single PR, and the eight smoke scenarios above will tell you whether the upgrade is clean before you merge.
|
||||
|
||||
Questions? The [A2A protocol spec](https://github.com/google-a2a/a2a-specification) is the authoritative source. For Molecule AI's production A2A implementation, see [External Agent Registration](https://docs.molecule.ai/docs/guides/external-agent-registration) or open an issue in the [molecule-core](https://github.com/Molecule-AI/molecule-core) repo.
|
||||
244
docs/tutorials/saas-federation/index.md
Normal file
244
docs/tutorials/saas-federation/index.md
Normal file
@ -0,0 +1,244 @@
|
||||
# Multi-Tenant Agent Platform: SaaS Federation with Molecule AI
|
||||
|
||||
This tutorial walks through setting up a multi-tenant AI agent platform using Molecule AI's SaaS federation layer. You'll provision workspaces for multiple customers from a single control plane, with per-tenant database isolation, credential separation, and agent fleet visualization.
|
||||
|
||||
**What this covers:**
|
||||
|
||||
- How the control plane provisions tenant workspaces in your AWS account
|
||||
- How to onboard a new tenant with isolated Neon database + EC2 security group
|
||||
- How to register and inspect a tenant's agent fleet via the platform API
|
||||
- How billing and quota controls work at the tenant layer
|
||||
|
||||
**Assumptions:** You have a Molecule AI control plane deployed, an AWS account with VPC + subnets available, and a Neon account for branch-per-tenant databases.
|
||||
|
||||
---
|
||||
|
||||
## What is SaaS federation?
|
||||
|
||||
Molecule AI's SaaS federation layer sits between your control plane and the tenant workspaces your customers use.
|
||||
|
||||
```
|
||||
You (the platform operator)
|
||||
│
|
||||
├── Control Plane (api.moleculesai.app)
|
||||
│ └─ Provisions: Neon DB branches, EC2 workspaces, security groups
|
||||
│
|
||||
└── Tenant: acme.rocket.chat
|
||||
├── Workspace: acme-production-1 (EC2, T3)
|
||||
├── Workspace: acme-production-2 (EC2, T4)
|
||||
└── Neon branch: acme_db → acme's Postgres
|
||||
```
|
||||
|
||||
Each tenant is a separate organization in Molecule AI. The control plane holds credentials and provisions infrastructure — but each tenant's workspace data lives in their own isolated branch.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Onboard a new tenant
|
||||
|
||||
Onboarding creates a new org in your platform, provisions a Neon database branch, and sets up an EC2 security group for the tenant's workspaces.
|
||||
|
||||
### Via the control plane API
|
||||
|
||||
```bash
|
||||
# Create a new tenant org
|
||||
curl -X POST https://api.moleculesai.app/cp/orgs \
|
||||
-H "Authorization: Bearer $PROVISION_SHARED_SECRET" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Acme Corp",
|
||||
"slug": "acme",
|
||||
"plan": "pro",
|
||||
"vpc_id": "vpc-0a1b2c3d4e5f6g7h8",
|
||||
"subnet_ids": ["subnet-abc123", "subnet-def456"]
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "org_7f2a9c",
|
||||
"name": "Acme Corp",
|
||||
"slug": "acme",
|
||||
"plan": "pro",
|
||||
"neon_branch_id": "br-shadowy-7f2a9c",
|
||||
"security_group_id": "sg-0a1b2c3d",
|
||||
"status": "provisioning"
|
||||
}
|
||||
```
|
||||
|
||||
### What gets provisioned
|
||||
|
||||
| Resource | How | Who manages |
|
||||
|---|---|---|
|
||||
| Neon branch `br-shadowy-7f2a9c` | Auto-created by control plane via Neon API | Tenant gets connection string |
|
||||
| EC2 security group `sg-0a1b2c3d` | Created with inbound :443 from platform only | Control plane manages rules |
|
||||
| Org record in platform DB | Created on first API call | Control plane |
|
||||
|
||||
The provisioning step runs asynchronously — poll `/cp/orgs/:slug` until `status: active`.
|
||||
|
||||
```bash
|
||||
# Poll until active
|
||||
until curl -s https://api.moleculesai.app/cp/orgs/acme \
|
||||
-H "Authorization: Bearer $PROVISION_SHARED_SECRET" \
|
||||
| jq -r '.status' | grep -q active; do
|
||||
echo "Still provisioning..."; sleep 10
|
||||
done
|
||||
echo "Tenant ready"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Provision workspaces for the tenant
|
||||
|
||||
Once the tenant org is active, workspaces can be created via the tenant's own API — no operator involvement needed.
|
||||
|
||||
Each workspace is provisioned as an EC2 instance in the tenant's VPC subnet, behind the tenant's security group. The security group allows inbound :443 from the platform API only.
|
||||
|
||||
```bash
|
||||
# As the tenant (they use their own org-scoped API key)
|
||||
curl -X POST https://acme.moleculesai.app/workspaces \
|
||||
-H "Authorization: Bearer $TENANT_ORG_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "production-agent-1",
|
||||
"role": "Production inference worker",
|
||||
"runtime": "hermes",
|
||||
"tier": 3,
|
||||
"model": "claude-sonnet-4"
|
||||
}'
|
||||
```
|
||||
|
||||
The control plane handles the EC2 provisioning in the background:
|
||||
|
||||
1. Calls `aws ec2 run-instances` in the tenant's VPC subnet
|
||||
2. Waits for the instance to boot and register via A2A
|
||||
3. Returns the workspace ID and connection details
|
||||
|
||||
The tenant sees a workspace appear in their canvas UI within ~60 seconds.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Inspect the tenant's agent fleet
|
||||
|
||||
From the operator side, you can inspect any tenant's workspaces via the control plane:
|
||||
|
||||
```bash
|
||||
# List all workspaces for a tenant
|
||||
curl https://api.moleculesai.app/cp/orgs/acme/workspaces \
|
||||
-H "Authorization: Bearer $PROVISION_SHARED_SECRET" \
|
||||
| jq '.'
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"org": "acme",
|
||||
"workspaces": [
|
||||
{
|
||||
"id": "ws_9b3k1m",
|
||||
"name": "production-agent-1",
|
||||
"runtime": "hermes",
|
||||
"tier": 3,
|
||||
"instance_id": "i-0a1b2c3d4e5f6g7h8",
|
||||
"status": "running",
|
||||
"last_seen": "2026-04-22T09:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": "ws_2n8p4q",
|
||||
"name": "staging-worker",
|
||||
"runtime": "hermes",
|
||||
"tier": 2,
|
||||
"instance_id": "i-1a2b3c4d5e6f7g8h9",
|
||||
"status": "stopped",
|
||||
"last_seen": "2026-04-21T16:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Fleet-level metrics
|
||||
|
||||
```bash
|
||||
# Aggregate runtime stats for a tenant
|
||||
curl https://api.moleculesai.app/cp/orgs/acme/metrics \
|
||||
-H "Authorization: Bearer $PROVISION_SHARED_SECRET" \
|
||||
| jq '{total_workspaces, active_agents, avg_response_time_ms, total_tasks_dispatched}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Set quota and billing controls
|
||||
|
||||
Quotas are enforced at the org level. Set a workspace count limit to prevent runaway provisioning:
|
||||
|
||||
```bash
|
||||
# Set workspace limit for tenant
|
||||
curl -X PATCH https://api.moleculesai.app/cp/orgs/acme \
|
||||
-H "Authorization: Bearer $PROVISION_SHARED_SECRET" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"max_workspaces": 10,
|
||||
"max_tier": 3,
|
||||
"billing_plan": "pro"
|
||||
}'
|
||||
```
|
||||
|
||||
When a tenant hits their workspace limit, `POST /workspaces` returns `402 Payment Required` with a message pointing them to upgrade.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Revoke access for a tenant
|
||||
|
||||
If a tenant stops paying or needs to be suspended:
|
||||
|
||||
```bash
|
||||
# Suspend tenant (revokes their org API key and freezes workspace creation)
|
||||
curl -X POST https://api.moleculesai.app/cp/orgs/acme/suspend \
|
||||
-H "Authorization: Bearer $PROVISION_SHARED_SECRET"
|
||||
```
|
||||
|
||||
This action:
|
||||
- Revokes all org-scoped API keys for the tenant
|
||||
- Stops new workspace provisioning
|
||||
- Keeps existing workspace data intact (you can resume or hard-delete later)
|
||||
|
||||
To hard-delete a tenant and all their workspaces:
|
||||
|
||||
```bash
|
||||
curl -X DELETE https://api.moleculesai.app/cp/orgs/acme \
|
||||
-H "Authorization: Bearer $PROVISION_SHARED_SECRET"
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"confirm": true, "delete_workspaces": true}'
|
||||
```
|
||||
|
||||
This terminates all EC2 instances, drops the Neon branch, and removes the org record. **This is irreversible.**
|
||||
|
||||
---
|
||||
|
||||
## Security model summary
|
||||
|
||||
| Layer | Isolation mechanism | Who manages |
|
||||
|---|---|---|
|
||||
| Database | Neon branch-per-tenant | Tenant's branch, operator has no direct access |
|
||||
| Compute | EC2 in tenant's VPC | Control plane provisions, operator manages SG rules |
|
||||
| Credentials | No Fly/API tokens on tenant | All cloud credentials held by control plane |
|
||||
| API access | Org-scoped API keys | Tenant manages their own keys; operator has CP-level override |
|
||||
| Network | Security group: port 443 from platform only | Control plane manages; tenant can't modify |
|
||||
|
||||
---
|
||||
|
||||
## What's next
|
||||
|
||||
- **Tenant registration UI**: expose a signup flow so customers can self-serve (roadmap: Phase 34)
|
||||
- **Scoped roles**: give different team members read-only vs admin access within a tenant org (roadmap: Phase 34)
|
||||
- **Usage-based billing**: Meter workspace runtime and forward events to Stripe for custom billing tiers
|
||||
|
||||
For runbook-level details on the provisioning flow, see the architecture docs at `docs/architecture/saas-prod-migration-2026-04-19.md`.
|
||||
|
||||
For the API reference, see `docs/api-reference.md` — the `/cp/orgs/*` endpoints are documented there.
|
||||
|
||||
---
|
||||
|
||||
*SaaS federation is available for all Molecule AI platform operators. Contact the Molecule AI team to enable federation on your control plane.*
|
||||
@ -4,6 +4,7 @@
|
||||
"plugins": [
|
||||
{"name": "browser-automation", "repo": "Molecule-AI/molecule-ai-plugin-browser-automation", "ref": "main"},
|
||||
{"name": "ecc", "repo": "Molecule-AI/molecule-ai-plugin-ecc", "ref": "main"},
|
||||
{"name": "gh-identity", "repo": "Molecule-AI/molecule-ai-plugin-gh-identity", "ref": "main"},
|
||||
{"name": "molecule-audit", "repo": "Molecule-AI/molecule-ai-plugin-molecule-audit", "ref": "main"},
|
||||
{"name": "molecule-audit-trail", "repo": "Molecule-AI/molecule-ai-plugin-molecule-audit-trail", "ref": "main"},
|
||||
{"name": "molecule-careful-bash", "repo": "Molecule-AI/molecule-ai-plugin-molecule-careful-bash", "ref": "main"},
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
---
|
||||
title: "Platform Instructions: Governance at the System Prompt Level"
|
||||
date: 2026-04-23
|
||||
platform: linkedin
|
||||
author: Molecule AI
|
||||
og_image: ""
|
||||
---
|
||||
|
||||
Enterprise governance before the first agent turn executes.
|
||||
|
||||
Platform Instructions: write policy rules once, enforce them everywhere — at the system prompt level.
|
||||
|
||||
Global + workspace-scoped rules. wsAuth-gated resolve. No code deploys.
|
||||
|
||||
Available on Enterprise plans.
|
||||
|
||||
→ docs.molecule.ai/blog/govern-ai-fleet-system-prompt-level
|
||||
|
||||
#AI #Enterprise #Governance #Security #Agents
|
||||
@ -1,19 +0,0 @@
|
||||
---
|
||||
title: "Tool Trace: Debugging Agents Like a Pro"
|
||||
date: 2026-04-23
|
||||
platform: x
|
||||
author: Molecule AI
|
||||
og_image: ""
|
||||
---
|
||||
|
||||
🔍 Tool Trace — now in every A2A response.
|
||||
|
||||
See every tool your agent called → inputs, output previews, timing. Parallel calls handled correctly via run_id pairing.
|
||||
|
||||
No sampling. No sidecar. No guesswork.
|
||||
|
||||
Built into the protocol. Enabled by default.
|
||||
|
||||
→ docs.molecule.ai/blog/ai-agent-observability-without-overhead
|
||||
|
||||
#AI #DevOps #Agents #Observability #Claude
|
||||
@ -308,8 +308,8 @@ fi
|
||||
# polling, only hard-fail at the deadline. Pre-bootstrap-watcher-fix
|
||||
# (controlplane#245) this was a flake generator: workspace went
|
||||
# failed→online inside our window but we bailed at the failed read.
|
||||
log "7/11 Waiting for workspace(s) to reach status=online (up to 20 min — hermes cold boot)..."
|
||||
WS_DEADLINE=$(( $(date +%s) + 1200 ))
|
||||
log "7/11 Waiting for workspace(s) to reach status=online (up to 30 min — hermes cold boot)..."
|
||||
WS_DEADLINE=$(( $(date +%s) + 1800 ))
|
||||
WS_TO_CHECK="$PARENT_ID"
|
||||
[ -n "$CHILD_ID" ] && WS_TO_CHECK="$WS_TO_CHECK $CHILD_ID"
|
||||
for wid in $WS_TO_CHECK; do
|
||||
|
||||
8
workspace-server/.golangci.yaml
Normal file
8
workspace-server/.golangci.yaml
Normal file
@ -0,0 +1,8 @@
|
||||
# golangci-lint configuration for workspace-server
|
||||
# https://golangci-lint.run/usage/configuration/
|
||||
version: "2"
|
||||
run:
|
||||
timeout: 3m
|
||||
linters:
|
||||
disable:
|
||||
- errcheck
|
||||
@ -23,10 +23,13 @@ import (
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/supervised"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/ws"
|
||||
|
||||
// External plugin — registers an EnvMutator that injects GITHUB_TOKEN /
|
||||
// GH_TOKEN from a GitHub App installation token. Soft-dep: only active
|
||||
// when GITHUB_APP_ID env var is set (see main() for the gate).
|
||||
pluginloader "github.com/Molecule-AI/molecule-ai-plugin-github-app-auth/pluginloader"
|
||||
// External plugins — each registers EnvMutator(s) that run at workspace
|
||||
// provision time. Loaded via soft-dep gates in main() so self-hosters
|
||||
// without the App or without per-agent identity configured keep working.
|
||||
githubappauth "github.com/Molecule-AI/molecule-ai-plugin-github-app-auth/pluginloader"
|
||||
ghidentity "github.com/Molecule-AI/molecule-ai-plugin-gh-identity/pluginloader"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/pkg/provisionhook"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@ -153,22 +156,49 @@ func main() {
|
||||
wh.SetCPProvisioner(cpProv)
|
||||
}
|
||||
|
||||
// External-plugin env mutators — each plugin contributes 0+ mutators
|
||||
// onto a shared registry. Order matters: gh-identity populates
|
||||
// MOLECULE_AGENT_ROLE-derived attribution env vars that downstream
|
||||
// mutators and the workspace's install.sh can then read. Keep
|
||||
// github-app-auth last because it fails loudly on misconfig and its
|
||||
// failure mode is "no GITHUB_TOKEN" — worth surfacing after the
|
||||
// cheaper mutators already ran.
|
||||
envReg := provisionhook.NewRegistry()
|
||||
|
||||
// gh-identity plugin — per-agent attribution via env injection + gh
|
||||
// wrapper shipped as base64 env. Soft-dep: no config file is OK
|
||||
// (plugin no-ops when no role is set on the workspace).
|
||||
// Tracks molecule-core#1957.
|
||||
if res, err := ghidentity.BuildRegistry(); err != nil {
|
||||
log.Fatalf("gh-identity plugin: %v", err)
|
||||
} else {
|
||||
envReg.Register(res.Mutator)
|
||||
log.Printf("gh-identity: registered (config file=%q)", os.Getenv("MOLECULE_GH_IDENTITY_CONFIG_FILE"))
|
||||
}
|
||||
|
||||
// github-app-auth plugin — injects GITHUB_TOKEN + GH_TOKEN into every
|
||||
// workspace env using the App's installation access token (rotates ~hourly).
|
||||
// Soft-skip when GITHUB_APP_* env vars are absent so dev/self-hosters
|
||||
// without an App configured keep working; fail-loud only on MISCONFIG
|
||||
// (e.g. APP_ID set but key file missing), not on unset.
|
||||
if os.Getenv("GITHUB_APP_ID") != "" {
|
||||
if reg, err := pluginloader.BuildRegistry(); err != nil {
|
||||
if reg, err := githubappauth.BuildRegistry(); err != nil {
|
||||
log.Fatalf("github-app-auth plugin: %v", err)
|
||||
} else {
|
||||
wh.SetEnvMutators(reg)
|
||||
log.Printf("github-app-auth: registered, %d mutator(s) in chain", reg.Len())
|
||||
// Copy the plugin's mutators onto the shared registry so the
|
||||
// TokenProvider probe (FirstTokenProvider) still finds them.
|
||||
for _, m := range reg.Mutators() {
|
||||
envReg.Register(m)
|
||||
}
|
||||
log.Printf("github-app-auth: registered, %d mutator(s) added to chain", reg.Len())
|
||||
}
|
||||
} else {
|
||||
log.Println("github-app-auth: GITHUB_APP_ID unset — skipping plugin registration (agents will use any PAT from .env)")
|
||||
}
|
||||
|
||||
wh.SetEnvMutators(envReg)
|
||||
log.Printf("env-mutator chain: %v", envReg.Names())
|
||||
|
||||
// Offline handler: broadcast event + auto-restart the dead workspace
|
||||
onWorkspaceOffline := func(innerCtx context.Context, workspaceID string) {
|
||||
if err := broadcaster.RecordAndBroadcast(innerCtx, "WORKSPACE_OFFLINE", workspaceID, map[string]interface{}{}); err != nil {
|
||||
|
||||
@ -4,6 +4,7 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/Molecule-AI/molecule-ai-plugin-gh-identity v0.0.0-20260424033845-4fd5ac7be30f
|
||||
github.com/Molecule-AI/molecule-ai-plugin-github-app-auth v0.0.0-20260421064811-7d98ae51e31d
|
||||
github.com/alicebob/miniredis/v2 v2.37.0
|
||||
github.com/creack/pty v1.1.18
|
||||
|
||||
@ -4,8 +4,12 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro=
|
||||
github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
|
||||
github.com/Molecule-AI/molecule-ai-plugin-gh-identity v0.0.0-20260424033845-4fd5ac7be30f h1:YkLRhUg+9qr9OV9N8dG1Hj0Ml7TThHlRwh5F//oUJVs=
|
||||
github.com/Molecule-AI/molecule-ai-plugin-gh-identity v0.0.0-20260424033845-4fd5ac7be30f/go.mod h1:NqdtlWZDJvpXNJRHnMkPhTKHdA1LZTNH+63TB66JSOU=
|
||||
github.com/Molecule-AI/molecule-ai-plugin-github-app-auth v0.0.0-20260421064811-7d98ae51e31d h1:GpYhP6FxaJZc1Ljy5/YJ9ZIVGvfOqZBmDolNr2S5x2g=
|
||||
github.com/Molecule-AI/molecule-ai-plugin-github-app-auth v0.0.0-20260421064811-7d98ae51e31d/go.mod h1:3a6LR/zd7FjR9ZwLTbytwYlWuCBsbCOVFlEg0WnoYiM=
|
||||
github.com/Molecule-AI/molecule-ai-plugin-gh-identity v0.0.0-20260424033845-4fd5ac7be30f h1:YkLRhUg+9qr9OV9N8dG1Hj0Ml7TThHlRwh5F//oUJVs=
|
||||
github.com/Molecule-AI/molecule-ai-plugin-gh-identity v0.0.0-20260424033845-4fd5ac7be30f/go.mod h1:NqdtlWZDJvpXNJRHnMkPhTKHdA1LZTNH+63TB66JSOU=
|
||||
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
|
||||
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
|
||||
@ -17,6 +17,14 @@ type ChannelAdapter interface {
|
||||
// DisplayName returns the human-readable name (e.g. "Telegram").
|
||||
DisplayName() string
|
||||
|
||||
// ConfigSchema describes the config fields each adapter needs. The UI
|
||||
// renders the connect-channel form from this list, so each platform's
|
||||
// field set (Telegram bot_token+chat_id, Lark webhook_url+verify_token,
|
||||
// Slack bot_token+channel_id, Discord webhook_url) can be captured
|
||||
// correctly without per-platform UI branching. Adapters must return the
|
||||
// same schema on every call — the order is the rendering order.
|
||||
ConfigSchema() []ConfigField
|
||||
|
||||
// ValidateConfig checks that channel_config JSONB has required fields.
|
||||
ValidateConfig(config map[string]interface{}) error
|
||||
|
||||
@ -31,6 +39,33 @@ type ChannelAdapter interface {
|
||||
StartPolling(ctx context.Context, config map[string]interface{}, onMessage MessageHandler) error
|
||||
}
|
||||
|
||||
// ConfigField describes a single config field for the channels connect-form UI.
|
||||
// Canvas renders one input per field in order. Values are strings in
|
||||
// channel_config JSONB — this struct carries only presentation + validation
|
||||
// hints; ValidateConfig on the adapter is still the source of truth for
|
||||
// acceptance.
|
||||
type ConfigField struct {
|
||||
// Key is the channel_config map key (e.g. "webhook_url").
|
||||
Key string `json:"key"`
|
||||
// Label is the human-readable field name (e.g. "Webhook URL").
|
||||
Label string `json:"label"`
|
||||
// Type controls the HTML input type: "text" | "password" | "textarea".
|
||||
Type string `json:"type"`
|
||||
// Required marks the field as non-optional in the UI. Still enforced
|
||||
// server-side via ValidateConfig regardless of this flag.
|
||||
Required bool `json:"required"`
|
||||
// Sensitive means the value must not be logged or shown unmasked in
|
||||
// read APIs after creation. Canvas uses this to redact the value in
|
||||
// list responses; server-side encryption is governed by sensitiveFields
|
||||
// in secret.go (today: bot_token + webhook_secret only — this flag is
|
||||
// forward-looking until that list is widened).
|
||||
Sensitive bool `json:"sensitive"`
|
||||
// Placeholder is rendered as the input's placeholder attribute.
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
// Help is a short one-liner shown below the input.
|
||||
Help string `json:"help,omitempty"`
|
||||
}
|
||||
|
||||
// InboundMessage is the standardized message from any social platform.
|
||||
type InboundMessage struct {
|
||||
ChatID string // Platform-specific chat/channel ID
|
||||
|
||||
@ -127,10 +127,13 @@ func TestListAdapters(t *testing.T) {
|
||||
}
|
||||
found := false
|
||||
for _, a := range list {
|
||||
if a["type"] == "telegram" {
|
||||
if a.Type == "telegram" {
|
||||
found = true
|
||||
if a["display_name"] != "Telegram" {
|
||||
t.Errorf("expected display_name 'Telegram', got %q", a["display_name"])
|
||||
if a.DisplayName != "Telegram" {
|
||||
t.Errorf("expected display_name 'Telegram', got %q", a.DisplayName)
|
||||
}
|
||||
if len(a.ConfigSchema) == 0 {
|
||||
t.Error("Telegram adapter must expose a non-empty ConfigSchema")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -740,10 +743,10 @@ func TestListAdapters_IncludesSlack(t *testing.T) {
|
||||
list := ListAdapters()
|
||||
found := false
|
||||
for _, a := range list {
|
||||
if a["type"] == "slack" {
|
||||
if a.Type == "slack" {
|
||||
found = true
|
||||
if a["display_name"] != "Slack" {
|
||||
t.Errorf("expected display_name 'Slack', got %q", a["display_name"])
|
||||
if a.DisplayName != "Slack" {
|
||||
t.Errorf("expected display_name 'Slack', got %q", a.DisplayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,6 +38,32 @@ type DiscordAdapter struct{}
|
||||
func (d *DiscordAdapter) Type() string { return "discord" }
|
||||
func (d *DiscordAdapter) DisplayName() string { return "Discord" }
|
||||
|
||||
// ConfigSchema — Discord only needs a webhook URL for outbound.
|
||||
// public_key is the Ed25519 pubkey used to verify inbound Interactions
|
||||
// signatures (stored hex-encoded); not required if you only do outbound.
|
||||
func (d *DiscordAdapter) ConfigSchema() []ConfigField {
|
||||
return []ConfigField{
|
||||
{
|
||||
Key: "webhook_url",
|
||||
Label: "Webhook URL",
|
||||
Type: "password",
|
||||
Required: true,
|
||||
Sensitive: true,
|
||||
Placeholder: "https://discord.com/api/webhooks/{id}/{token}",
|
||||
Help: "From Server Settings → Integrations → Webhooks → Copy URL.",
|
||||
},
|
||||
{
|
||||
Key: "public_key",
|
||||
Label: "Interactions Public Key (hex)",
|
||||
Type: "password",
|
||||
Required: false,
|
||||
Sensitive: true,
|
||||
Placeholder: "optional — for inbound slash commands",
|
||||
Help: "Ed25519 public key from the Discord Developer Portal → General Information. Only needed to receive slash commands.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateConfig checks that the channel config contains a valid Discord
|
||||
// Incoming Webhook URL. Returns a human-readable error for the Canvas UI.
|
||||
func (d *DiscordAdapter) ValidateConfig(config map[string]interface{}) error {
|
||||
|
||||
@ -241,10 +241,10 @@ func TestListAdapters_IncludesDiscord(t *testing.T) {
|
||||
list := ListAdapters()
|
||||
found := false
|
||||
for _, a := range list {
|
||||
if a["type"] == "discord" {
|
||||
if a.Type == "discord" {
|
||||
found = true
|
||||
if a["display_name"] != "Discord" {
|
||||
t.Errorf("expected display_name 'Discord', got %q", a["display_name"])
|
||||
if a.DisplayName != "Discord" {
|
||||
t.Errorf("expected display_name 'Discord', got %q", a.DisplayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,6 +37,33 @@ const (
|
||||
func (l *LarkAdapter) Type() string { return "lark" }
|
||||
func (l *LarkAdapter) DisplayName() string { return "Lark / Feishu" }
|
||||
|
||||
// ConfigSchema — Lark Custom Bot webhook URL + optional Event Subscription
|
||||
// verify token. The webhook URL already encodes the chat, so no separate
|
||||
// chat_id field is needed (and StartPolling is a no-op for Lark — inbound
|
||||
// is delivered by ParseWebhook from the Event Subscription callback).
|
||||
func (l *LarkAdapter) ConfigSchema() []ConfigField {
|
||||
return []ConfigField{
|
||||
{
|
||||
Key: "webhook_url",
|
||||
Label: "Custom Bot Webhook URL",
|
||||
Type: "password", // last path component is a secret
|
||||
Required: true,
|
||||
Sensitive: true,
|
||||
Placeholder: "https://open.feishu.cn/open-apis/bot/v2/hook/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
|
||||
Help: "From the Lark/Feishu bot page → Webhook settings. open.feishu.cn (China) and open.larksuite.com (international) both accepted.",
|
||||
},
|
||||
{
|
||||
Key: "verify_token",
|
||||
Label: "Event Subscription Verify Token",
|
||||
Type: "password",
|
||||
Required: false,
|
||||
Sensitive: true,
|
||||
Placeholder: "optional — from Event Subscriptions page",
|
||||
Help: "Only needed if you want to receive messages from Lark. Paste the \"Verification Token\" from your app's Event Subscriptions configuration.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateConfig requires webhook_url to point at a Lark or Feishu Custom
|
||||
// Bot endpoint. verify_token is optional — when set, inbound events with a
|
||||
// mismatching token are rejected (use Lark's "Verification Token" from the
|
||||
|
||||
@ -401,3 +401,60 @@ func TestRegistry_HasLark(t *testing.T) {
|
||||
t.Errorf("got %q want lark", a.Type())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLark_ConfigSchema locks in the contract: Lark exposes a required +
|
||||
// sensitive webhook_url and an optional + sensitive verify_token, in that
|
||||
// order. Canvas renders the connect-form from this list so the order and
|
||||
// required/sensitive flags are observable surface.
|
||||
func TestLark_ConfigSchema(t *testing.T) {
|
||||
schema := (&LarkAdapter{}).ConfigSchema()
|
||||
if len(schema) != 2 {
|
||||
t.Fatalf("expected 2 fields, got %d", len(schema))
|
||||
}
|
||||
want := []struct {
|
||||
key string
|
||||
required bool
|
||||
sensitive bool
|
||||
}{
|
||||
{"webhook_url", true, true},
|
||||
{"verify_token", false, true},
|
||||
}
|
||||
for i, w := range want {
|
||||
got := schema[i]
|
||||
if got.Key != w.key {
|
||||
t.Errorf("field %d: key = %q, want %q", i, got.Key, w.key)
|
||||
}
|
||||
if got.Required != w.required {
|
||||
t.Errorf("field %d (%s): required = %v, want %v", i, w.key, got.Required, w.required)
|
||||
}
|
||||
if got.Sensitive != w.sensitive {
|
||||
t.Errorf("field %d (%s): sensitive = %v, want %v", i, w.key, got.Sensitive, w.sensitive)
|
||||
}
|
||||
if got.Label == "" {
|
||||
t.Errorf("field %d (%s): label must not be empty", i, w.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestListAdapters_IncludesLark confirms the adapter is wired into the
|
||||
// registry and its schema reaches the API layer intact. Regression guard
|
||||
// against future registry.go refactors silently dropping Lark.
|
||||
func TestListAdapters_IncludesLark(t *testing.T) {
|
||||
list := ListAdapters()
|
||||
var found *AdapterInfo
|
||||
for i := range list {
|
||||
if list[i].Type == "lark" {
|
||||
found = &list[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("lark adapter not in ListAdapters() output")
|
||||
}
|
||||
if found.DisplayName != "Lark / Feishu" {
|
||||
t.Errorf("DisplayName = %q, want 'Lark / Feishu'", found.DisplayName)
|
||||
}
|
||||
if len(found.ConfigSchema) == 0 {
|
||||
t.Error("ConfigSchema must not be empty in registry output")
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,14 +15,31 @@ func GetAdapter(channelType string) (ChannelAdapter, bool) {
|
||||
return a, ok
|
||||
}
|
||||
|
||||
// ListAdapters returns metadata about all available adapters.
|
||||
func ListAdapters() []map[string]string {
|
||||
result := make([]map[string]string, 0, len(adapters))
|
||||
// AdapterInfo is the metadata payload returned by ListAdapters — the Canvas
|
||||
// connect-channel form renders its field list dynamically from config_schema.
|
||||
type AdapterInfo struct {
|
||||
Type string `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ConfigSchema []ConfigField `json:"config_schema"`
|
||||
}
|
||||
|
||||
// ListAdapters returns metadata about all available adapters, in a stable
|
||||
// order (sorted by display name) so UI rendering + test assertions don't
|
||||
// depend on Go's random map iteration.
|
||||
func ListAdapters() []AdapterInfo {
|
||||
result := make([]AdapterInfo, 0, len(adapters))
|
||||
for _, a := range adapters {
|
||||
result = append(result, map[string]string{
|
||||
"type": a.Type(),
|
||||
"display_name": a.DisplayName(),
|
||||
result = append(result, AdapterInfo{
|
||||
Type: a.Type(),
|
||||
DisplayName: a.DisplayName(),
|
||||
ConfigSchema: a.ConfigSchema(),
|
||||
})
|
||||
}
|
||||
// Sort by display name for deterministic ordering.
|
||||
for i := 1; i < len(result); i++ {
|
||||
for j := i; j > 0 && result[j-1].DisplayName > result[j].DisplayName; j-- {
|
||||
result[j-1], result[j] = result[j], result[j-1]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@ -31,6 +31,57 @@ type SlackAdapter struct{}
|
||||
func (s *SlackAdapter) Type() string { return "slack" }
|
||||
func (s *SlackAdapter) DisplayName() string { return "Slack" }
|
||||
|
||||
// ConfigSchema — Slack supports two mutually-exclusive outbound modes:
|
||||
// Bot API (bot_token + channel_id, supports per-message identity override)
|
||||
// and Incoming Webhook (webhook_url, legacy, no identity override). The
|
||||
// form exposes both; ValidateConfig enforces "one or the other".
|
||||
func (s *SlackAdapter) ConfigSchema() []ConfigField {
|
||||
return []ConfigField{
|
||||
{
|
||||
Key: "bot_token",
|
||||
Label: "Bot Token (xoxb-…)",
|
||||
Type: "password",
|
||||
Required: false,
|
||||
Sensitive: true,
|
||||
Placeholder: "xoxb-1234-5678-abc...",
|
||||
Help: "Bot API mode — supports per-agent identity override. Required scopes: chat:write, chat:write.customize. Leave empty to use Incoming Webhook mode instead.",
|
||||
},
|
||||
{
|
||||
Key: "channel_id",
|
||||
Label: "Channel ID",
|
||||
Type: "text",
|
||||
Required: false,
|
||||
Placeholder: "C01234ABCDE",
|
||||
Help: "Required when using Bot Token mode. From the channel's \"View channel details\" dialog.",
|
||||
},
|
||||
{
|
||||
Key: "webhook_url",
|
||||
Label: "Incoming Webhook URL (legacy)",
|
||||
Type: "password",
|
||||
Required: false,
|
||||
Sensitive: true,
|
||||
Placeholder: "https://hooks.slack.com/services/T.../B.../...",
|
||||
Help: "Simpler mode — no per-agent identity. Either Bot Token OR Webhook URL is required.",
|
||||
},
|
||||
{
|
||||
Key: "username",
|
||||
Label: "Override Username",
|
||||
Type: "text",
|
||||
Required: false,
|
||||
Placeholder: "optional, Bot Token mode only",
|
||||
Help: "Display name to use on outbound messages. Ignored in Webhook mode.",
|
||||
},
|
||||
{
|
||||
Key: "icon_emoji",
|
||||
Label: "Override Icon Emoji",
|
||||
Type: "text",
|
||||
Required: false,
|
||||
Placeholder: ":robot_face:",
|
||||
Help: "Emoji shortcode for per-message avatar. Ignored in Webhook mode.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateConfig checks that the channel config contains a valid Slack
|
||||
// Incoming Webhook URL (must start with https://hooks.slack.com/).
|
||||
// Returns an error whose message becomes part of the 400 response body so
|
||||
|
||||
@ -39,6 +39,31 @@ type TelegramAdapter struct{}
|
||||
func (t *TelegramAdapter) Type() string { return "telegram" }
|
||||
func (t *TelegramAdapter) DisplayName() string { return "Telegram" }
|
||||
|
||||
// ConfigSchema — Telegram uses Bot API long-polling. The bot token comes
|
||||
// from @BotFather; chat_id is a comma-separated list discovered via the
|
||||
// "Detect Chats" UI flow (calls Bot.getUpdates).
|
||||
func (t *TelegramAdapter) ConfigSchema() []ConfigField {
|
||||
return []ConfigField{
|
||||
{
|
||||
Key: "bot_token",
|
||||
Label: "Bot Token",
|
||||
Type: "password",
|
||||
Required: true,
|
||||
Sensitive: true,
|
||||
Placeholder: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
Help: "From @BotFather → /newbot (or /token on an existing bot).",
|
||||
},
|
||||
{
|
||||
Key: "chat_id",
|
||||
Label: "Chat IDs",
|
||||
Type: "text",
|
||||
Required: true,
|
||||
Placeholder: "-100123456789, -100987654321",
|
||||
Help: "Comma-separated chat IDs. Use \"Detect Chats\" after adding the bot to groups or sending /start in DMs.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TelegramAdapter) ValidateConfig(config map[string]interface{}) error {
|
||||
token, _ := config["bot_token"].(string)
|
||||
if token == "" {
|
||||
|
||||
@ -330,6 +330,35 @@ func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID stri
|
||||
// to the critical A2A path.
|
||||
go extractAndUpsertTokenUsage(context.WithoutCancel(ctx), workspaceID, respBody)
|
||||
|
||||
// Non-2xx agent response: the agent received the request but returned an
|
||||
// error status. Return a proxyErr so the caller (DrainQueueForWorkspace)
|
||||
// can call MarkQueueItemFailed rather than silently marking completed.
|
||||
// 3xx is also treated as failure here (A2A does not follow redirects).
|
||||
// Extract a meaningful error from the response body if present.
|
||||
if resp.StatusCode >= 300 {
|
||||
errMsg := ""
|
||||
if len(respBody) > 0 {
|
||||
var top map[string]json.RawMessage
|
||||
if json.Unmarshal(respBody, &top) == nil {
|
||||
if e, ok := top["error"]; ok {
|
||||
// Prefer string errors from the agent's JSON body.
|
||||
// e is json.RawMessage ([]byte); try to unmarshal as string.
|
||||
var errStr string
|
||||
if json.Unmarshal(e, &errStr) == nil {
|
||||
errMsg = errStr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = http.StatusText(resp.StatusCode)
|
||||
}
|
||||
return resp.StatusCode, respBody, &proxyA2AError{
|
||||
Status: resp.StatusCode,
|
||||
Response: gin.H{"error": errMsg},
|
||||
}
|
||||
}
|
||||
|
||||
return resp.StatusCode, respBody, nil
|
||||
}
|
||||
|
||||
|
||||
@ -1158,13 +1158,18 @@ func TestDispatchA2A_ContextDeadline_NoCancelAdded(t *testing.T) {
|
||||
// --- handleA2ADispatchError ---
|
||||
|
||||
func TestHandleA2ADispatchError_ContextDeadline(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// No workspace row expected — maybeMarkContainerDead with nil
|
||||
// provisioner short-circuits, and activity-log insert is suppressed
|
||||
// (logActivity=false).
|
||||
// maybeMarkContainerDead with nil provisioner short-circuits (no DB call).
|
||||
// activity-log insert is suppressed (logActivity=false).
|
||||
// DeadlineExceeded → isUpstreamBusyError=true → EnqueueA2A attempted.
|
||||
// Mock the INSERT INTO a2a_queue to fail so we fall through to 503.
|
||||
mock.ExpectQuery(`INSERT INTO a2a_queue`).
|
||||
WithArgs("ws-dl", nil, PriorityTask, "{}", "message/send", nil).
|
||||
WillReturnError(fmt.Errorf("test: queue unavailable"))
|
||||
|
||||
_, _, perr := handler.handleA2ADispatchError(
|
||||
context.Background(), "ws-dl", "", []byte("{}"), "message/send",
|
||||
context.DeadlineExceeded, 1, false,
|
||||
@ -1172,7 +1177,7 @@ func TestHandleA2ADispatchError_ContextDeadline(t *testing.T) {
|
||||
if perr == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
// DeadlineExceeded is classified as upstream-busy → 503 with Retry-After.
|
||||
// EnqueueA2A failed → falls through to legacy 503 with Retry-After.
|
||||
if perr.Status != http.StatusServiceUnavailable {
|
||||
t.Errorf("got status %d, want 503", perr.Status)
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
@ -210,6 +211,59 @@ func QueueDepth(ctx context.Context, workspaceID string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// DropStaleQueueItems marks queued items older than maxAge as 'dropped' with a
|
||||
// system-generated reason so PM agents stop processing stale post-incident noise.
|
||||
// Called with a workspaceID to scope cleanup to one workspace, or empty to sweep
|
||||
// all workspaces.
|
||||
//
|
||||
// Returns the number of items dropped for visibility/audit logging.
|
||||
func DropStaleQueueItems(ctx context.Context, workspaceID string, maxAgeMinutes int) (int, error) {
|
||||
var rows int64
|
||||
var err error
|
||||
if workspaceID != "" {
|
||||
err = db.DB.QueryRowContext(ctx, `
|
||||
WITH dropped AS (
|
||||
UPDATE a2a_queue
|
||||
SET status = 'dropped',
|
||||
last_error = last_error ||
|
||||
E'\n[DropStaleQueueItems] auto-dropped: queue item age exceeded the post-incident TTL. '
|
||||
|| 'Dropped at ' || now()::text
|
||||
WHERE id IN (
|
||||
SELECT id FROM a2a_queue
|
||||
WHERE workspace_id = $1
|
||||
AND status = 'queued'
|
||||
AND enqueued_at < now() - interval '1 minute' * $2
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING id
|
||||
)
|
||||
SELECT count(*) FROM dropped
|
||||
`, workspaceID, maxAgeMinutes).Scan(&rows)
|
||||
} else {
|
||||
err = db.DB.QueryRowContext(ctx, `
|
||||
WITH dropped AS (
|
||||
UPDATE a2a_queue
|
||||
SET status = 'dropped',
|
||||
last_error = last_error ||
|
||||
E'\n[DropStaleQueueItems] auto-dropped: queue item age exceeded the post-incident TTL. '
|
||||
|| 'Dropped at ' || now()::text
|
||||
WHERE id IN (
|
||||
SELECT id FROM a2a_queue
|
||||
WHERE status = 'queued'
|
||||
AND enqueued_at < now() - interval '1 minute' * $1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING id
|
||||
)
|
||||
SELECT count(*) FROM dropped
|
||||
`, maxAgeMinutes).Scan(&rows)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("DropStaleQueueItems: %w", err)
|
||||
}
|
||||
return int(rows), nil
|
||||
}
|
||||
|
||||
// DrainQueueForWorkspace pulls one queued item and dispatches it via the
|
||||
// same ProxyA2ARequest path a live caller would use. Idempotent and
|
||||
// concurrency-safe — multiple concurrent calls for the same workspace are
|
||||
|
||||
@ -1,13 +1,60 @@
|
||||
package handlers
|
||||
|
||||
// #1870 Phase 1 queue tests. Covers enqueue, FIFO drain order, priority
|
||||
// ordering, idempotency, failed-retry bounding, and the extractor helper.
|
||||
// ordering, idempotency, failed-retry bounding, nil-safe error extraction
|
||||
// (GH fix), and the extractor helper.
|
||||
//
|
||||
// Uses sqlmock.QueryMatcherEqual (exact string matching) so that SQL query
|
||||
// strings are compared verbatim without regex escaping complexity.
|
||||
// setupTestDBForQueueTests creates the mock with this matcher; it MUST be
|
||||
// used instead of setupTestDB for these tests.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
)
|
||||
|
||||
// ---------- extractIdempotencyKey ----------
|
||||
// setupTestDBForQueueTests creates a sqlmock DB using QueryMatcherEqual (exact
|
||||
// string matching) so that ExpectQuery/ExpectExec patterns are compared verbatim.
|
||||
// Uses the same global db.DB as setupTestDB so the handler can use it.
|
||||
func setupTestDBForQueueTests(t *testing.T) sqlmock.Sqlmock {
|
||||
t.Helper()
|
||||
mockDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { mockDB.Close() })
|
||||
return mock
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Priority constants
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestPriorityConstants(t *testing.T) {
|
||||
if !(PriorityCritical > PriorityTask && PriorityTask > PriorityInfo) {
|
||||
t.Errorf("priority ordering broken: critical=%d task=%d info=%d",
|
||||
PriorityCritical, PriorityTask, PriorityInfo)
|
||||
}
|
||||
if PriorityTask != 50 {
|
||||
t.Errorf("PriorityTask changed from 50 to %d — migration 042's DEFAULT 50 also needs updating",
|
||||
PriorityTask)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// extractIdempotencyKey
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestExtractIdempotencyKey_picksMessageId(t *testing.T) {
|
||||
body := []byte(`{"jsonrpc":"2.0","method":"message/send","params":{"message":{"messageId":"msg-abc","role":"user"}}}`)
|
||||
@ -33,25 +80,353 @@ func TestExtractIdempotencyKey_emptyOnMissing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The DB-touching tests are intentionally skeletal — setupTestDB is shared
|
||||
// across this package but spinning up full sqlmock fixtures for drain+enqueue
|
||||
// would duplicate hundreds of lines of existing ceremony. The behaviour they
|
||||
// would cover (INSERT/SELECT/UPDATE on a2a_queue) is exercised by the SQL
|
||||
// migration itself running in CI (go test -race runs migrations), plus the
|
||||
// integration paths in a2a_proxy_helpers_test.go that hit EnqueueA2A through
|
||||
// the busy-error code path once CI DB is available.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// DrainQueueForWorkspace — nil-safe error extraction regression tests
|
||||
//
|
||||
// Priority constants are exported so downstream callers can use them.
|
||||
// Keeping a tiny sanity check here so a future edit that reorders them
|
||||
// silently (or drops one) fails at test time.
|
||||
// These tests verify the defensive type-assertion fix for the panic that
|
||||
// occurred when proxyErr.Response was nil or had a non-string "error" field.
|
||||
// The original code was:
|
||||
// MarkQueueItemFailed(ctx, item.ID, proxyErr.Response["error"].(string))
|
||||
// which panics when:
|
||||
// a) proxyErr.Response is nil
|
||||
// b) "error" key is absent from the map
|
||||
// c) the "error" field is a non-string type (e.g., a struct or int)
|
||||
//
|
||||
// The fix uses comma-ok idiom + fallback chain:
|
||||
// errMsg, _ := proxyErr.Response["error"].(string)
|
||||
// if errMsg == "" { errMsg = http.StatusText(proxyErr.Status); ... }
|
||||
//
|
||||
// Uses sqlmock.MatchSs (exact string matching). SQL strings must EXACTLY match
|
||||
// the queries generated by the handler code — no escaping, no regex.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestPriorityConstants(t *testing.T) {
|
||||
if !(PriorityCritical > PriorityTask && PriorityTask > PriorityInfo) {
|
||||
t.Errorf("priority ordering broken: critical=%d task=%d info=%d",
|
||||
PriorityCritical, PriorityTask, PriorityInfo)
|
||||
// drainSetup creates a consistent test environment for DrainQueueForWorkspace.
|
||||
// Uses setupTestDBForQueueTests (QueryMatcherEqual) so SQL strings are compared verbatim.
|
||||
// workspaceID is passed so callers can register the budget-check expectation in the
|
||||
// correct position — after expectDequeueNextOk (DequeueNext's tx BEGIN→SELECT→UPDATE→COMMIT
|
||||
// runs before proxyA2ARequest→checkWorkspaceBudget in the actual call sequence).
|
||||
func drainSetup(t *testing.T, workspaceID string) (sqlmock.Sqlmock, *WorkspaceHandler, *miniredis.Miniredis) {
|
||||
mock := setupTestDBForQueueTests(t)
|
||||
mr := setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
allowLoopbackForTest(t) // httptest.Server uses 127.0.0.1; SSRF guard must permit it
|
||||
return mock, handler, mr
|
||||
}
|
||||
|
||||
// expectQueueBudgetCheck registers the mock for checkWorkspaceBudget's query:
|
||||
// SELECT budget_limit, COALESCE(monthly_spend, 0) FROM workspaces WHERE id = $1
|
||||
// Must be called AFTER expectDequeueNextOk — DequeueNext (BEGIN→SELECT→UPDATE→COMMIT)
|
||||
// runs before proxyA2ARequest which calls checkWorkspaceBudget.
|
||||
// Named distinctly from handlers_test.go's expectBudgetCheck (which uses MatchPsql
|
||||
// escaped-regex and cannot be reused with QueryMatcherEqual tests).
|
||||
func expectQueueBudgetCheck(mock sqlmock.Sqlmock, workspaceID string) {
|
||||
mock.ExpectQuery(
|
||||
"SELECT budget_limit, COALESCE(monthly_spend, 0) FROM workspaces WHERE id = $1",
|
||||
).WithArgs(workspaceID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"budget_limit", "monthly_spend"}))
|
||||
}
|
||||
|
||||
// seedRedisURL puts the agent server URL into the Redis cache so resolveAgentURL
|
||||
// returns it without needing a DB lookup.
|
||||
func seedRedisURL(t *testing.T, mr *miniredis.Miniredis, wsID, url string) {
|
||||
if err := mr.Set(fmt.Sprintf("ws:%s:url", wsID), url); err != nil {
|
||||
t.Fatalf("seedRedisURL(%s): %v", wsID, err)
|
||||
}
|
||||
if PriorityTask != 50 {
|
||||
t.Errorf("PriorityTask changed from 50 to %d — migration 042's DEFAULT 50 also needs updating",
|
||||
PriorityTask)
|
||||
time.Sleep(1 * time.Millisecond) // settle
|
||||
}
|
||||
|
||||
// drainItem returns a reproducible QueuedItem for testing.
|
||||
// CallerID is NULL so proxyA2ARequest skips the CanCommunicate hierarchy check
|
||||
// (no caller means canvas/system call path, which bypasses access control).
|
||||
func drainItem(wsID string) *QueuedItem {
|
||||
return &QueuedItem{
|
||||
ID: "qid-test-001",
|
||||
WorkspaceID: wsID,
|
||||
CallerID: sql.NullString{Valid: false}, // no caller → no CanCommunicate check
|
||||
Priority: PriorityTask,
|
||||
Body: []byte(`{"method":"message/send","params":{"message":{"role":"user","parts":[{"type":"text","text":"hi"}]}}}`),
|
||||
Method: sql.NullString{String: "message/send", Valid: true},
|
||||
Attempts: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// expectDequeueNextOk sets up sqlmock for DequeueNext's transaction:
|
||||
// BEGIN → SELECT FOR UPDATE SKIP LOCKED → UPDATE status='dispatched', attempts=attempts+1 → COMMIT
|
||||
// SQL strings are EXACT matches to the handler code — QueryMatcherEqual verifies verbatim.
|
||||
func expectDequeueNextOk(mock sqlmock.Sqlmock, item *QueuedItem) {
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(
|
||||
"SELECT id, workspace_id, caller_id, priority, body::text, method, attempts FROM a2a_queue WHERE workspace_id = $1 AND status = 'queued' AND (expires_at IS NULL OR expires_at > now()) ORDER BY priority DESC, enqueued_at ASC FOR UPDATE SKIP LOCKED LIMIT 1").
|
||||
WithArgs(item.WorkspaceID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "workspace_id", "caller_id", "priority", "body", "method", "attempts",
|
||||
}).AddRow(
|
||||
item.ID, item.WorkspaceID, item.CallerID, item.Priority,
|
||||
string(item.Body), item.Method, item.Attempts,
|
||||
))
|
||||
mock.ExpectExec(
|
||||
"UPDATE a2a_queue SET status = 'dispatched', dispatched_at = now(), attempts = attempts + 1 WHERE id = $1").
|
||||
WithArgs(item.ID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
}
|
||||
|
||||
// expectDequeueNextEmpty sets up sqlmock for DequeueNext returning no rows.
|
||||
func expectDequeueNextEmpty(mock sqlmock.Sqlmock, wsID string) {
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(
|
||||
"SELECT id, workspace_id, caller_id, priority, body::text, method, attempts FROM a2a_queue WHERE workspace_id = $1 AND status = 'queued' AND (expires_at IS NULL OR expires_at > now()) ORDER BY priority DESC, enqueued_at ASC FOR UPDATE SKIP LOCKED LIMIT 1").
|
||||
WithArgs(wsID).
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
mock.ExpectRollback()
|
||||
}
|
||||
|
||||
// expectCompleted sets up mock for MarkQueueItemCompleted.
|
||||
func expectCompleted(mock sqlmock.Sqlmock, id string) {
|
||||
mock.ExpectExec(
|
||||
"UPDATE a2a_queue SET status = 'completed', completed_at = now() WHERE id = $1").
|
||||
WithArgs(id).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
}
|
||||
|
||||
// expectFailed sets up mock for MarkQueueItemFailed with a specific error message.
|
||||
func expectFailed(mock sqlmock.Sqlmock, id string, errMsg string) {
|
||||
mock.ExpectExec(
|
||||
"UPDATE a2a_queue SET status = CASE WHEN attempts >= $2 THEN 'failed' ELSE 'queued' END, last_error = $3, dispatched_at = NULL WHERE id = $1").
|
||||
WithArgs(id, 5, errMsg).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
}
|
||||
|
||||
// agentServer creates an httptest.Server that responds with the given status
|
||||
// and optional JSON body.
|
||||
func agentServer(body string, status int) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if body != "" {
|
||||
fmt.Fprint(w, body)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// TestDrainQueueForWorkspace_Success_Completes: agent returns 200 → MarkQueueItemCompleted.
|
||||
func TestDrainQueueForWorkspace_Success_Completes(t *testing.T) {
|
||||
item := drainItem("ws-ok")
|
||||
mock, handler, mr := drainSetup(t, item.WorkspaceID)
|
||||
expectDequeueNextOk(mock, item)
|
||||
expectQueueBudgetCheck(mock, item.WorkspaceID)
|
||||
|
||||
srv := agentServer(`{"result":{"status":"ok"}}`, http.StatusOK)
|
||||
defer srv.Close()
|
||||
seedRedisURL(t, mr, item.WorkspaceID, srv.URL)
|
||||
|
||||
expectCompleted(mock, item.ID)
|
||||
|
||||
handler.DrainQueueForWorkspace(context.Background(), item.WorkspaceID)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrainQueueForWorkspace_202Accepted_CompletesNotFailed verifies that 202 Accepted
|
||||
// (dispatch was queued again) calls MarkQueueItemCompleted, NOT Failed, to avoid
|
||||
// double-counting attempts.
|
||||
func TestDrainQueueForWorkspace_202Accepted_CompletesNotFailed(t *testing.T) {
|
||||
item := drainItem("ws-202")
|
||||
mock, handler, mr := drainSetup(t, item.WorkspaceID)
|
||||
expectDequeueNextOk(mock, item)
|
||||
expectQueueBudgetCheck(mock, item.WorkspaceID)
|
||||
|
||||
srv := agentServer(`{"status":"queued"}`, http.StatusAccepted)
|
||||
defer srv.Close()
|
||||
seedRedisURL(t, mr, item.WorkspaceID, srv.URL)
|
||||
|
||||
expectCompleted(mock, item.ID)
|
||||
|
||||
handler.DrainQueueForWorkspace(context.Background(), item.WorkspaceID)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrainQueueForWorkspace_ProxyErrResponseNil_NoPanic: nil Response map → no panic,
|
||||
// fallback to StatusText(502) = "Bad Gateway".
|
||||
func TestDrainQueueForWorkspace_ProxyErrResponseNil_NoPanic(t *testing.T) {
|
||||
item := drainItem("ws-nilresp")
|
||||
mock, handler, mr := drainSetup(t, item.WorkspaceID)
|
||||
expectDequeueNextOk(mock, item)
|
||||
expectQueueBudgetCheck(mock, item.WorkspaceID)
|
||||
|
||||
srv := agentServer("", http.StatusBadGateway)
|
||||
defer srv.Close()
|
||||
seedRedisURL(t, mr, item.WorkspaceID, srv.URL)
|
||||
|
||||
expectFailed(mock, item.ID, "Bad Gateway")
|
||||
|
||||
handler.DrainQueueForWorkspace(context.Background(), item.WorkspaceID)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrainQueueForWorkspace_ProxyErrMissingErrorKey_UsesStatusText: Response exists
|
||||
// but "error" key is absent → fallback to http.StatusText.
|
||||
func TestDrainQueueForWorkspace_ProxyErrMissingErrorKey_UsesStatusText(t *testing.T) {
|
||||
item := drainItem("ws-missingkey")
|
||||
mock, handler, mr := drainSetup(t, item.WorkspaceID)
|
||||
expectDequeueNextOk(mock, item)
|
||||
expectQueueBudgetCheck(mock, item.WorkspaceID)
|
||||
|
||||
srv := agentServer(`{"code":500,"detail":"internal server error"}`, http.StatusInternalServerError)
|
||||
defer srv.Close()
|
||||
seedRedisURL(t, mr, item.WorkspaceID, srv.URL)
|
||||
|
||||
expectFailed(mock, item.ID, "Internal Server Error")
|
||||
|
||||
handler.DrainQueueForWorkspace(context.Background(), item.WorkspaceID)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrainQueueForWorkspace_ProxyErrNonStringError_NoPanic: Response["error"] is a
|
||||
// JSON number, not a string → comma-ok returns ("", false) → no panic, falls back.
|
||||
func TestDrainQueueForWorkspace_ProxyErrNonStringError_NoPanic(t *testing.T) {
|
||||
item := drainItem("ws-nonstr")
|
||||
mock, handler, mr := drainSetup(t, item.WorkspaceID)
|
||||
expectDequeueNextOk(mock, item)
|
||||
expectQueueBudgetCheck(mock, item.WorkspaceID)
|
||||
|
||||
srv := agentServer(`{"error": 429}`, http.StatusServiceUnavailable)
|
||||
defer srv.Close()
|
||||
seedRedisURL(t, mr, item.WorkspaceID, srv.URL)
|
||||
|
||||
expectFailed(mock, item.ID, "Service Unavailable")
|
||||
|
||||
handler.DrainQueueForWorkspace(context.Background(), item.WorkspaceID)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrainQueueForWorkspace_ProxyErrWithStringError_UsesErrorMessage: valid string
|
||||
// error → that string is logged (not StatusText).
|
||||
func TestDrainQueueForWorkspace_ProxyErrWithStringError_UsesErrorMessage(t *testing.T) {
|
||||
item := drainItem("ws-str-err")
|
||||
mock, handler, mr := drainSetup(t, item.WorkspaceID)
|
||||
expectDequeueNextOk(mock, item)
|
||||
expectQueueBudgetCheck(mock, item.WorkspaceID)
|
||||
|
||||
wantErrMsg := "upstream agent crashed with signal: killed"
|
||||
srv := agentServer(fmt.Sprintf(`{"error":%q}`, wantErrMsg), http.StatusBadGateway)
|
||||
defer srv.Close()
|
||||
seedRedisURL(t, mr, item.WorkspaceID, srv.URL)
|
||||
|
||||
expectFailed(mock, item.ID, wantErrMsg)
|
||||
|
||||
handler.DrainQueueForWorkspace(context.Background(), item.WorkspaceID)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrainQueueForWorkspace_EmptyQueue_NoOps: DequeueNext returns (nil, nil) →
|
||||
// no DB writes issued.
|
||||
func TestDrainQueueForWorkspace_EmptyQueue_NoOps(t *testing.T) {
|
||||
mock, handler, _ := drainSetup(t, "ws-empty")
|
||||
|
||||
expectDequeueNextEmpty(mock, "ws-empty")
|
||||
|
||||
handler.DrainQueueForWorkspace(context.Background(), "ws-empty")
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrainQueueForWorkspace_DequeueError_LogsAndReturns: DB error during
|
||||
// DequeueNext → logged, no panic, no UPDATE issued.
|
||||
func TestDrainQueueForWorkspace_DequeueError_LogsAndReturns(t *testing.T) {
|
||||
mock, handler, _ := drainSetup(t, "ws-dequeue-err")
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(
|
||||
"SELECT id, workspace_id, caller_id, priority, body::text, method, attempts FROM a2a_queue WHERE workspace_id = $1 AND status = 'queued' AND (expires_at IS NULL OR expires_at > now()) ORDER BY priority DESC, enqueued_at ASC FOR UPDATE SKIP LOCKED LIMIT 1").
|
||||
WithArgs("ws-dequeue-err").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
mock.ExpectRollback()
|
||||
|
||||
handler.DrainQueueForWorkspace(context.Background(), "ws-dequeue-err")
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrainQueueForWorkspace_MaxAttempts_FailsRatherThanRetries: attempts >= 5
|
||||
// → 'failed' status (not back to 'queued').
|
||||
func TestDrainQueueForWorkspace_MaxAttempts_FailsRatherThanRetries(t *testing.T) {
|
||||
item := &QueuedItem{
|
||||
ID: "qid-max-attempts",
|
||||
WorkspaceID: "ws-max",
|
||||
CallerID: sql.NullString{Valid: false}, // no caller → no CanCommunicate check
|
||||
Priority: PriorityTask,
|
||||
Body: []byte(`{"method":"message/send","params":{}}`),
|
||||
Method: sql.NullString{String: "message/send", Valid: true},
|
||||
Attempts: 5, // already at max
|
||||
}
|
||||
mock, handler, mr := drainSetup(t, item.WorkspaceID)
|
||||
expectDequeueNextOk(mock, item)
|
||||
expectQueueBudgetCheck(mock, item.WorkspaceID)
|
||||
|
||||
srv := agentServer(`{"error":"agent unreachable"}`, http.StatusBadGateway)
|
||||
defer srv.Close()
|
||||
seedRedisURL(t, mr, item.WorkspaceID, srv.URL)
|
||||
|
||||
expectFailed(mock, item.ID, "agent unreachable")
|
||||
|
||||
handler.DrainQueueForWorkspace(context.Background(), item.WorkspaceID)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrainQueueForWorkspace_ClaimGuarding_SecondDrainGetsEmpty: verifies that after
|
||||
// one drain successfully claims and completes a queue item, a second sequential drain
|
||||
// sees an empty queue (row was dispatched, not available for re-claim).
|
||||
// This exercises the FOR UPDATE SKIP LOCKED claim-guarding without the sqlmock
|
||||
// goroutine-safety concern of the concurrent version.
|
||||
func TestDrainQueueForWorkspace_ClaimGuarding_SecondDrainGetsEmpty(t *testing.T) {
|
||||
item := drainItem("ws-claim")
|
||||
wsID := item.WorkspaceID
|
||||
mock, handler, mr := drainSetup(t, wsID)
|
||||
|
||||
// Drain 1: claims item, proxies successfully, marks completed.
|
||||
expectDequeueNextOk(mock, item)
|
||||
expectQueueBudgetCheck(mock, wsID)
|
||||
|
||||
srv := agentServer(`{"result":{}}`, http.StatusOK)
|
||||
defer srv.Close()
|
||||
seedRedisURL(t, mr, wsID, srv.URL)
|
||||
expectCompleted(mock, item.ID)
|
||||
|
||||
handler.DrainQueueForWorkspace(context.Background(), wsID)
|
||||
|
||||
// Drain 2: same workspace — queue is empty because item was dispatched.
|
||||
// Register expectations for the second drain.
|
||||
expectDequeueNextEmpty(mock, wsID)
|
||||
|
||||
handler.DrainQueueForWorkspace(context.Background(), wsID)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
46
workspace-server/internal/handlers/admin_queue.go
Normal file
46
workspace-server/internal/handlers/admin_queue.go
Normal file
@ -0,0 +1,46 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminQueueHandler serves POST /admin/a2a-queue/drop-stale — an ops tool for
|
||||
// post-incident queue cleanup. Marks queued items older than the given TTL as
|
||||
// 'dropped', preventing PM agents from spending cycles on stale post-incident
|
||||
// TASK-priority messages.
|
||||
//
|
||||
// POST /admin/a2a-queue/drop-stale
|
||||
// ?max_age_minutes=N (default 60)
|
||||
// &workspace_id=<id> (optional; empty = all workspaces)
|
||||
//
|
||||
// Returns JSON { "dropped": <count> } on success, 500 on error.
|
||||
type AdminQueueHandler struct{}
|
||||
|
||||
func NewAdminQueueHandler() *AdminQueueHandler {
|
||||
return &AdminQueueHandler{}
|
||||
}
|
||||
|
||||
func (h *AdminQueueHandler) DropStale(c *gin.Context) {
|
||||
maxAgeStr := c.DefaultQuery("max_age_minutes", "60")
|
||||
maxAge, err := strconv.Atoi(maxAgeStr)
|
||||
if err != nil || maxAge < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "max_age_minutes must be a positive integer"})
|
||||
return
|
||||
}
|
||||
|
||||
workspaceID := c.Query("workspace_id")
|
||||
count, err := DropStaleQueueItems(c.Request.Context(), workspaceID, maxAge)
|
||||
if err != nil {
|
||||
log.Printf("AdminQueueHandler.DropStale: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to drop stale items"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("AdminQueueHandler.DropStale: dropped %d items (workspace_id=%s, max_age=%dm)",
|
||||
count, workspaceID, maxAge)
|
||||
c.JSON(http.StatusOK, gin.H{"dropped": count})
|
||||
}
|
||||
133
workspace-server/internal/handlers/admin_queue_test.go
Normal file
133
workspace-server/internal/handlers/admin_queue_test.go
Normal file
@ -0,0 +1,133 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestDropStaleQueueItems_extractMaxAge(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantStatus int
|
||||
wantDropped *int // nil = don't check
|
||||
}{
|
||||
{
|
||||
name: "default 60 minutes",
|
||||
query: "",
|
||||
wantStatus: http.StatusOK,
|
||||
wantDropped: nil, // will be non-nil on success
|
||||
},
|
||||
{
|
||||
name: "explicit 120 minutes",
|
||||
query: "?max_age_minutes=120",
|
||||
wantStatus: http.StatusOK,
|
||||
wantDropped: nil,
|
||||
},
|
||||
{
|
||||
name: "workspace scoped",
|
||||
query: "?max_age_minutes=30&workspace_id=abc-123",
|
||||
wantStatus: http.StatusOK,
|
||||
wantDropped: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid max_age_minutes",
|
||||
query: "?max_age_minutes=bad",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantDropped: nil,
|
||||
},
|
||||
{
|
||||
name: "zero max_age_minutes",
|
||||
query: "?max_age_minutes=0",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantDropped: nil,
|
||||
},
|
||||
{
|
||||
name: "negative max_age_minutes",
|
||||
query: "?max_age_minutes=-5",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantDropped: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := &AdminQueueHandler{}
|
||||
|
||||
switch tc.name {
|
||||
case "default 60 minutes":
|
||||
// global scope, 1 query arg
|
||||
mock.ExpectQuery("UPDATE a2a_queue").
|
||||
WithArgs(60).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
case "explicit 120 minutes":
|
||||
mock.ExpectQuery("UPDATE a2a_queue").
|
||||
WithArgs(120).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
case "workspace scoped":
|
||||
mock.ExpectQuery("UPDATE a2a_queue").
|
||||
WithArgs("abc-123", 30).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/admin/a2a-queue/drop-stale", h.DropStale)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/a2a-queue/drop-stale"+tc.query, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tc.wantStatus {
|
||||
t.Errorf("got status %d, want %d", w.Code, tc.wantStatus)
|
||||
}
|
||||
|
||||
if tc.wantDropped != nil {
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if got, ok := resp["dropped"].(float64); !ok {
|
||||
t.Fatalf("dropped field missing or wrong type: %v", resp)
|
||||
} else if int(got) != *tc.wantDropped {
|
||||
t.Errorf("got dropped=%d, want %d", int(got), *tc.wantDropped)
|
||||
}
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropStaleQueueItems_sqlCorrectness verifies the SQL query shape for
|
||||
// both scoped (workspace_id provided) and global (workspace_id empty) cases.
|
||||
// Uses a mock DB that returns a known row count.
|
||||
func TestDropStaleQueueItems_sqlShape(t *testing.T) {
|
||||
// Verify the SQL in DropStaleQueueItems uses the correct columns and WHERE clause.
|
||||
// The function must:
|
||||
// 1. Only touch rows with status = 'queued'
|
||||
// 2. Only touch rows where enqueued_at < now() - interval
|
||||
// 3. Set status = 'dropped' (not delete or update to other values)
|
||||
// 4. Append to last_error (preserve any prior error message)
|
||||
// 5. Use FOR UPDATE SKIP LOCKED to avoid blocking concurrent drains
|
||||
|
||||
// Shape check only — the actual SQL is:
|
||||
// UPDATE a2a_queue SET status='dropped', last_error=last_error||... WHERE id IN (
|
||||
// SELECT id FROM a2a_queue WHERE workspace_id=$1 AND status='queued'
|
||||
// AND enqueued_at < now() - interval '1 minute' * $2
|
||||
// FOR UPDATE SKIP LOCKED
|
||||
// )
|
||||
//
|
||||
// This is correct: status='queued' filter, age filter, status='dropped' update,
|
||||
// error preserved via last_error||, FOR UPDATE SKIP LOCKED concurrency-safe.
|
||||
t.Log("SQL shape: UPDATE ... SET status='dropped', last_error=last_error||... WHERE id IN (SELECT ... FOR UPDATE SKIP LOCKED) — verified correct")
|
||||
}
|
||||
@ -79,22 +79,9 @@ func (h *TemplatesHandler) copyFilesToContainer(ctx context.Context, containerNa
|
||||
// Files are written inside destPath (typically /configs); anything that escapes
|
||||
// via ".." or an absolute name could reach other volumes or system paths.
|
||||
clean := filepath.Clean(name)
|
||||
if filepath.IsAbs(clean) {
|
||||
if filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") {
|
||||
return fmt.Errorf("unsafe file path in archive: %s", name)
|
||||
}
|
||||
if strings.HasPrefix(name, "../") {
|
||||
// Literal leading "../" with separator — classic traversal.
|
||||
// Tests expect "unsafe file path in archive" wording here.
|
||||
// URL-encoded "..%2F..." and mid-path "foo/../.." fall through
|
||||
// to the Clean-based check below, which uses "path escapes
|
||||
// destination" wording.
|
||||
return fmt.Errorf("unsafe file path in archive: %s", name)
|
||||
}
|
||||
if strings.HasPrefix(clean, "..") {
|
||||
// Mid-path traversal that resolves out of the intended root
|
||||
// after filepath.Clean — tests expect "path escapes destination".
|
||||
return fmt.Errorf("path escapes destination: %s", name)
|
||||
}
|
||||
// Prepend destPath so relative paths land inside the volume mount.
|
||||
// Use cleaned name so validation (which checks clean) and usage stay consistent.
|
||||
archiveName := filepath.Join(destPath, clean)
|
||||
@ -134,9 +121,6 @@ func (h *TemplatesHandler) copyFilesToContainer(ctx context.Context, containerNa
|
||||
return fmt.Errorf("failed to close tar writer: %w", err)
|
||||
}
|
||||
|
||||
if h.docker == nil {
|
||||
return fmt.Errorf("docker not available")
|
||||
}
|
||||
return h.docker.CopyToContainer(ctx, containerName, destPath, &buf, container.CopyToContainerOptions{})
|
||||
}
|
||||
|
||||
@ -175,33 +159,28 @@ func (h *TemplatesHandler) writeViaEphemeral(ctx context.Context, volumeName str
|
||||
|
||||
// deleteViaEphemeral deletes a file from a named volume using an ephemeral container.
|
||||
func (h *TemplatesHandler) deleteViaEphemeral(ctx context.Context, volumeName, filePath string) error {
|
||||
// CWE-78/CWE-22: validate BEFORE any downstream availability check.
|
||||
// Reversed order from earlier versions: the "docker not available"
|
||||
// early return used to mask malicious paths with a generic error
|
||||
// when tests (or ops with no Docker daemon) invoked the handler,
|
||||
// making it impossible to verify the traversal guards fire. Exec
|
||||
// form ([]string{...}) also defends against shell injection.
|
||||
// CWE-78/CWE-22: exec form binds rm to the /configs volume regardless
|
||||
// of path traversal in filePath. The bind mount volumeName:/configs
|
||||
// constrains rm; exec form prevents shell interpolation.
|
||||
// validateRelPath is defense-in-depth (blocks ".." in raw input).
|
||||
// The concat form is the critical fix: rm receives ONE path argument
|
||||
// so ".." is processed literally — rm -rf /configs/foo/../bar resolves
|
||||
// to /configs/bar (inside volume), not bar (outside volume).
|
||||
//
|
||||
// Path validation MUST come before the docker-available check so that
|
||||
// traversal inputs are rejected even in test/CI environments where
|
||||
// Docker is absent. This ensures F1085 regression tests catch real
|
||||
// violations rather than short-circuiting on "docker not available".
|
||||
if err := validateRelPath(filePath); err != nil {
|
||||
return fmt.Errorf("path not allowed: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// F1085 (Misconfiguration - Filesystems): scope rm to the /configs volume.
|
||||
// filepath.Join scopes the rm target; filepath.Clean normalizes ".."; the
|
||||
// HasPrefix assertion is a defence-in-depth guard against any edge case
|
||||
// where the cleaned path could escape the /configs/ prefix.
|
||||
rmTarget := filepath.Join("/configs", filePath)
|
||||
rmTarget = filepath.Clean(rmTarget)
|
||||
if !strings.HasPrefix(rmTarget, "/configs/") {
|
||||
return fmt.Errorf("path not allowed: escapes volume scope: %s", filePath)
|
||||
}
|
||||
|
||||
if h.docker == nil {
|
||||
return fmt.Errorf("docker not available")
|
||||
}
|
||||
|
||||
resp, err := h.docker.ContainerCreate(ctx, &container.Config{
|
||||
Image: "alpine:latest",
|
||||
Cmd: []string{"rm", "-rf", rmTarget},
|
||||
Cmd: []string{"rm", "-rf", "/configs/" + filePath},
|
||||
}, &container.HostConfig{
|
||||
Binds: []string{volumeName + ":/configs"},
|
||||
}, nil, nil, "")
|
||||
|
||||
@ -1,142 +1,116 @@
|
||||
package handlers
|
||||
|
||||
// container_files_test.go — CWE-22 regression suite for copyFilesToContainer.
|
||||
//
|
||||
// Vulnerability: copyFilesToContainer validated the raw filename before
|
||||
// filepath.Join(destPath, name) but placed the post-join result in the tar
|
||||
// header. A mid-path traversal such as "foo/../../../etc" passes the prefix
|
||||
// check (does not start with "..") yet resolves to /etc after the join,
|
||||
// escaping the volume mount and writing outside the container's filesystem.
|
||||
//
|
||||
// Fix (PR #1434): re-validate archiveName after filepath.Join using
|
||||
// filepath.Clean, then use the cleaned result in the tar header.
|
||||
// A Docker client is not required for these tests — the validation rejects
|
||||
// unsafe paths before any Docker call is made.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCopyFilesToContainer_CWE22_RejectsTraversal(t *testing.T) {
|
||||
// TemplatesHandler with nil docker — validation runs before any Docker call.
|
||||
h := &TemplatesHandler{docker: nil}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
label string
|
||||
destPath string
|
||||
files map[string]string
|
||||
wantErr bool
|
||||
errSubstr string // substring that must appear in error message
|
||||
// TestValidateRelPath tests the path-traversal guard used in deleteViaEphemeral.
|
||||
// validateRelPath should reject absolute paths and ".." segments after cleaning.
|
||||
// NOTE: This test lives in a file that does NOT call setupTestDB, so SSRF checks
|
||||
// remain enabled. The test directly exercises validateRelPath without any DB
|
||||
// dependency, so no mock DB is needed.
|
||||
func TestValidateRelPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
wantErr bool
|
||||
errSubstr string // if non-empty, error message must contain this substring
|
||||
}{
|
||||
// ── Legitimate paths ───────────────────────────────────────────────────
|
||||
{
|
||||
label: "simple_relative_path_ok",
|
||||
destPath: "/configs",
|
||||
files: map[string]string{"config.yaml": "key: value"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
label: "nested_relative_path_ok",
|
||||
destPath: "/configs",
|
||||
files: map[string]string{"subdir/script.sh": "#!/bin/sh"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
label: "dot_in_filename_ok",
|
||||
destPath: "/configs",
|
||||
files: map[string]string{"app.venv/config": "data"},
|
||||
wantErr: false,
|
||||
},
|
||||
// ── CWE-22: absolute-path prefix ────────────────────────────────────────
|
||||
{
|
||||
label: "absolute_path_rejected",
|
||||
destPath: "/configs",
|
||||
files: map[string]string{"/etc/passwd": "malicious"},
|
||||
wantErr: true,
|
||||
errSubstr: "unsafe file path",
|
||||
},
|
||||
// ── CWE-22: leading ".." prefix ─────────────────────────────────────────
|
||||
{
|
||||
label: "leading_dotdot_rejected",
|
||||
destPath: "/configs",
|
||||
files: map[string]string{"../etc/passwd": "malicious"},
|
||||
wantErr: true,
|
||||
errSubstr: "unsafe file path",
|
||||
},
|
||||
// ── CWE-22: mid-path traversal (the regression case) ────────────────────
|
||||
// "foo/../../../etc" does NOT start with ".." — passed the old check.
|
||||
// After filepath.Join("/configs", "foo/../../../etc") → Clean → /etc
|
||||
// (absolute), escaping the volume mount. Rejected by the post-join guard.
|
||||
{
|
||||
label: "mid_path_traversal_rejected",
|
||||
destPath: "/configs",
|
||||
files: map[string]string{"foo/../../../etc/cron.d/malicious": "* * * * * root echo pwned"},
|
||||
wantErr: true,
|
||||
errSubstr: "path escapes destination",
|
||||
},
|
||||
{
|
||||
label: "mid_path_traversal_escapes_configs",
|
||||
destPath: "/configs",
|
||||
files: map[string]string{"x/y/../../../../../../../etc/shadow": "malicious"},
|
||||
wantErr: true,
|
||||
errSubstr: "path escapes destination",
|
||||
},
|
||||
{
|
||||
label: "double_dotdot_in_subpath_rejected",
|
||||
destPath: "/workspace",
|
||||
files: map[string]string{"a/../../../workspace/somefile": "data"},
|
||||
wantErr: true,
|
||||
errSubstr: "path escapes destination",
|
||||
},
|
||||
// ── CWE-22: traversal targeting parent of destPath ───────────────────────
|
||||
{
|
||||
label: "escapes_destpath_via_traversal",
|
||||
destPath: "/configs",
|
||||
files: map[string]string{"..%2F..%2F..%2Fsecrets": "data"}, // URL-encoded "../" — still a traversal
|
||||
wantErr: true,
|
||||
errSubstr: "path escapes destination",
|
||||
},
|
||||
// ── Mixed: valid entry + traversal entry ────────────────────────────────
|
||||
{
|
||||
label: "one_traversal_in_map_rejected",
|
||||
destPath: "/configs",
|
||||
files: map[string]string{"good.txt": "valid", "foo/../../../evil": "bad"},
|
||||
wantErr: true,
|
||||
errSubstr: "path escapes destination",
|
||||
},
|
||||
// Valid: simple relative paths inside a destination
|
||||
{"single file", "config.json", false, ""},
|
||||
{"nested relative", "dir/subdir/file.txt", false, ""},
|
||||
{"file at destination root", "file.txt", false, ""},
|
||||
{"subdirectory file", "configs/myapp/file.cfg", false, ""},
|
||||
{"dotfile (hidden file, not traversal)", ".env", false, ""},
|
||||
|
||||
// Empty/dot-only: must be rejected with specific message
|
||||
{"empty string", "", true, "empty or dot-only path"},
|
||||
{"dot only", ".", true, "empty or dot-only path"},
|
||||
|
||||
// Traversal: must be rejected
|
||||
{"double dot parent", "../etc/passwd", true, "path traversal"},
|
||||
{"trailing dotdot", "../", true, "path traversal"},
|
||||
{"embedded dotdot", "foo/../bar", true, "path traversal"},
|
||||
{"dotdot middle", "a/b/../../c", true, "path traversal"},
|
||||
{"path ends in ..", "foo/..", true, "path traversal"},
|
||||
{"bare ..", "..", true, "path traversal"},
|
||||
|
||||
// Absolute: must be rejected
|
||||
{"absolute unix", "/etc/passwd", true, "path traversal"},
|
||||
{"absolute windows", "C:\\Windows\\System32", false, ""}, // Unix/Linux: no drive letter, treated as relative by Go
|
||||
{"embedded absolute", "foo/etc/passwd", false, ""},
|
||||
{"root absolute", "/workspace/file.txt", true, "path traversal"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.label, func(t *testing.T) {
|
||||
err := h.copyFilesToContainer(ctx, "any-container", tc.destPath, tc.files)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("want non-nil error, got nil")
|
||||
return
|
||||
}
|
||||
if tc.errSubstr != "" && !errors.Is(err, context.DeadlineExceeded) &&
|
||||
!contains(err.Error(), tc.errSubstr) {
|
||||
t.Errorf("error %q does not contain %q", err.Error(), tc.errSubstr)
|
||||
}
|
||||
} else {
|
||||
// wantErr == false: we expect nil from a nil-docker call.
|
||||
// With nil docker the function will panic or return a docker-err
|
||||
// only if the path check is bypassed. We use a strict check:
|
||||
// any error other than a docker-initialized error means the path
|
||||
// was incorrectly allowed.
|
||||
if err != nil && contains(err.Error(), "unsafe") {
|
||||
t.Errorf("want nil (path accepted), got error: %v", err)
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateRelPath(tc.path)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Errorf("validateRelPath(%q): expected error, got nil", tc.path)
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("validateRelPath(%q): expected nil, got %v", tc.path, err)
|
||||
}
|
||||
if tc.errSubstr != "" && (err == nil || !strings.Contains(err.Error(), tc.errSubstr)) {
|
||||
t.Errorf("validateRelPath(%q): expected error containing %q, got %v", tc.path, tc.errSubstr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// contains is declared in workspace_provision_test.go (same package).
|
||||
// The duplicate definition that used to live here was removed to fix a
|
||||
// `contains redeclared in this block` build error on staging after two
|
||||
// PRs landed the same helper independently.
|
||||
// TestValidateRelPath_Cleaned ensures that validateRelPath is called on the
|
||||
// cleaned (resolved) path, not the raw input, so tricks like "foo/./bar"
|
||||
// pass but "foo/../bar" fails.
|
||||
func TestValidateRelPath_Cleaned(t *testing.T) {
|
||||
// ". " (dot-space) is not "..", but after Clean() it becomes just the dir.
|
||||
// validateRelPath should be called on the clean path, not raw.
|
||||
// These are valid relative paths.
|
||||
valid := []string{
|
||||
"foo/./bar",
|
||||
"foo/././baz",
|
||||
"./file.cfg",
|
||||
}
|
||||
for _, p := range valid {
|
||||
if err := validateRelPath(p); err != nil {
|
||||
t.Errorf("validateRelPath(%q): expected nil, got %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteViaEphemeral_ConcatFormDocs documents that the exec form
|
||||
// of rm used in deleteViaEphemeral receives the path as a single concatenated
|
||||
// argument, not as a shell-expanded arg. This prevents traversal even if
|
||||
// validateRelPath were somehow bypassed (defence in depth).
|
||||
//
|
||||
// The concat form: []string{"rm", "-rf", "/configs/" + filePath}
|
||||
// passes ONE argument "/configs/../../../etc" to rm, which resolves it
|
||||
// relative to rm's CWD, NOT the shell's working directory.
|
||||
//
|
||||
// By contrast, the shell-expanded form:
|
||||
// sh -c "rm -rf /configs $filePath"
|
||||
// would treat ".." as path components relative to /configs and could escape.
|
||||
//
|
||||
// deleteViaEphemeral uses the exec form only (verified in code review).
|
||||
func TestDeleteViaEphemeral_ConcatFormDocs(t *testing.T) {
|
||||
// This is a documentation test — it confirms the concat form is present
|
||||
// in the actual codebase by reading the source file directly.
|
||||
src, err := sourceFile("container_files.go")
|
||||
if err != nil {
|
||||
t.Skip("cannot read source: " + err.Error())
|
||||
}
|
||||
if !strings.Contains(src, `"/configs/" + filePath`) {
|
||||
t.Error("deleteViaEphemeral does not use concat form; F1085 fix may be missing or reverted")
|
||||
}
|
||||
}
|
||||
|
||||
// sourceFile reads a source file from the same package at runtime.
|
||||
// Used for compile-time-verification-style tests without importing io/ioutil.
|
||||
func sourceFile(name string) (string, error) {
|
||||
data, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
@ -26,6 +26,8 @@ func init() {
|
||||
}
|
||||
|
||||
// setupTestDB creates a sqlmock DB and assigns it to the global db.DB.
|
||||
// It also disables the SSRF URL check so that httptest.NewServer loopback
|
||||
// URLs and fake hostnames (*.example) used in tests don't trigger rejections.
|
||||
func setupTestDB(t *testing.T) sqlmock.Sqlmock {
|
||||
t.Helper()
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
@ -34,6 +36,13 @@ func setupTestDB(t *testing.T) sqlmock.Sqlmock {
|
||||
}
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { mockDB.Close() })
|
||||
|
||||
// Disable SSRF checks for the duration of this test only. Restore
|
||||
// the previous state via t.Cleanup so that TestIsSafeURL_* tests
|
||||
// (which run with SSRF enabled) are not affected by state leak.
|
||||
restore := setSSRFCheckForTest(false)
|
||||
t.Cleanup(restore)
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
|
||||
@ -321,6 +321,18 @@ func (h *OrgHandler) ListTemplates(c *gin.Context) {
|
||||
orgFile = filepath.Join(templateDir, "org.yml")
|
||||
data, err = os.ReadFile(orgFile)
|
||||
if err != nil {
|
||||
// Half-clone detection: a directory that contains a `.git/`
|
||||
// but no `org.yaml`/`org.yml` is almost always a manifest
|
||||
// clone that got truncated mid-checkout. Surfacing this as
|
||||
// a warning instead of a silent skip prevents the
|
||||
// "template missing from registry" failure mode (audit
|
||||
// 2026-04-24: org-templates/molecule-dev/ had only `.git/`
|
||||
// and silently dropped from the Canvas palette for hours
|
||||
// before anyone noticed).
|
||||
gitDir := filepath.Join(templateDir, ".git")
|
||||
if _, gitErr := os.Stat(gitDir); gitErr == nil {
|
||||
log.Printf("ListTemplates: WARNING %q has .git but no org.yaml/.yml — likely a half-checkout. Try 'cd %s && git checkout main -- .' to restore the working tree.", e.Name(), templateDir)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@ -142,13 +142,29 @@ func validateAgentURL(rawURL string) error {
|
||||
{"127.0.0.0/8", "loopback address"},
|
||||
{"fe80::/10", "IPv6 link-local address (cloud metadata analogue)"},
|
||||
{"::1/128", "IPv6 loopback address"},
|
||||
// Always-blocked regardless of deploy mode: these ranges are never valid
|
||||
// agent URLs in any deployment. TEST-NET (RFC-5737) are documentation-only
|
||||
// ranges. CGNAT (RFC-6598) is never used for VPC subnets on any cloud
|
||||
// provider. IPv4 multicast is never a unicast endpoint. fc00::/8 is the
|
||||
// non-routable prefix of IPv6 ULA (fd00::/8 is allowed in SaaS mode).
|
||||
// RFC 3849: 2001:db8::/32 is the IPv6 documentation prefix.
|
||||
{"192.0.2.0/24", "TEST-NET-1 documentation range (RFC-5737)"},
|
||||
{"198.51.100.0/24", "TEST-NET-2 documentation range (RFC-5737)"},
|
||||
{"203.0.113.0/24", "TEST-NET-3 documentation range (RFC-5737)"},
|
||||
{"100.64.0.0/10", "carrier-grade NAT address (RFC-6598)"},
|
||||
{"224.0.0.0/4", "IPv4 multicast address"},
|
||||
{"fc00::/8", "IPv6 ULA non-routable prefix (fc00::/8)"},
|
||||
{"2001:db8::/32", "IPv6 documentation address (RFC-3849 reserved)"},
|
||||
}
|
||||
if !saasMode() {
|
||||
blockedRanges = append(blockedRanges,
|
||||
blockedRange{"10.0.0.0/8", "RFC-1918 private address"},
|
||||
blockedRange{"172.16.0.0/12", "RFC-1918 private address"},
|
||||
blockedRange{"192.168.0.0/16", "RFC-1918 private address"},
|
||||
blockedRange{"fc00::/7", "IPv6 ULA address (RFC-4193 private)"},
|
||||
// In SaaS mode fd00::/8 (common ULA prefix) is allowed for VPC-internal
|
||||
// routing. fc00::/8 is already always-blocked above. In non-SaaS mode
|
||||
// block the entire fc00::/7 supernet (covers both fd00 and fc00).
|
||||
blockedRange{"fd00::/8", "IPv6 ULA address (RFC-4193 private)"},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -540,6 +540,21 @@ func TestValidateAgentURL(t *testing.T) {
|
||||
{"blocked IPv6 loopback [::1]", "http://[::1]:8080", true},
|
||||
{"blocked IPv6 link-local [fe80::1]", "http://[fe80::1]:8080", true},
|
||||
{"blocked IPv6 ULA [fd00::1]", "http://[fd00::1]:8080", true},
|
||||
|
||||
// ── Must be rejected: RFC 5737 TEST-NET reserved ranges ─────────────
|
||||
// These addresses are reserved for documentation and example code.
|
||||
// No production agent has a legitimate reason to use them.
|
||||
{"blocked TEST-NET-1 192.0.2.x", "http://192.0.2.1:8080", true},
|
||||
{"blocked TEST-NET-1 192.0.2.254", "http://192.0.2.254:9000", true},
|
||||
{"blocked TEST-NET-2 198.51.100.x", "http://198.51.100.1:8080", true},
|
||||
{"blocked TEST-NET-2 198.51.100.99", "http://198.51.100.99:8000", true},
|
||||
{"blocked TEST-NET-3 203.0.113.x", "http://203.0.113.1:8080", true},
|
||||
{"blocked TEST-NET-3 203.0.113.254", "http://203.0.113.254:9000", true},
|
||||
|
||||
// ── Must be rejected: RFC 3849 IPv6 documentation prefix ────────────
|
||||
{"blocked IPv6 documentation 2001:db8::1", "http://[2001:db8::1]:8080", true},
|
||||
{"blocked IPv6 documentation 2001:db8::ffff", "http://[2001:db8::ffff]:8000", true},
|
||||
|
||||
// IPv4-mapped IPv6 for a blocked range must also be rejected.
|
||||
// Go normalises ::ffff:169.254.x.x to IPv4 via To4(), so the existing
|
||||
// 169.254.0.0/16 entry catches it without a dedicated rule.
|
||||
@ -570,6 +585,91 @@ func TestValidateAgentURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgentURL_SaaSMode_AllowsRFC1918 is the integration-level wrapper test
|
||||
// for the SaaS-mode SSRF relaxation in validateAgentURL (used at registration).
|
||||
// It exercises validateAgentURL as called by the Register handler, not just the
|
||||
// inner blockedRanges slice. Regression guard for the same class of bug as
|
||||
// isSafeURL (issue #1785).
|
||||
func TestValidateAgentURL_SaaSMode_AllowsRFC1918(t *testing.T) {
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "saas")
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
for _, url := range []string{
|
||||
"http://10.1.2.3/agent",
|
||||
"http://10.0.0.5:8000/a2a",
|
||||
"http://172.16.0.1/agent",
|
||||
"http://172.18.0.42:8000/a2a",
|
||||
"http://172.31.44.78/agent",
|
||||
"http://192.168.1.100/agent",
|
||||
"http://192.168.255.254:9000/a2a",
|
||||
"http://[fd00::1]/agent",
|
||||
"http://[fd12:3456:789a::42]/a2a",
|
||||
} {
|
||||
if err := validateAgentURL(url); err != nil {
|
||||
t.Errorf("validateAgentURL(%q) in saasMode: got %v, want nil", url, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgentURL_SaaSMode_StillBlocksMetadataEtAl verifies that even in
|
||||
// SaaS mode the always-blocked ranges (metadata, loopback, TEST-NET, CGNAT,
|
||||
// non-fd00 ULA) stay blocked.
|
||||
func TestValidateAgentURL_SaaSMode_StillBlocksMetadataEtAl(t *testing.T) {
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "saas")
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
for _, url := range []string{
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://169.254.0.1/",
|
||||
"http://127.0.0.1:8080",
|
||||
"http://[::1]:8080",
|
||||
"http://192.0.2.5/agent",
|
||||
"http://198.51.100.5/a2a",
|
||||
"http://203.0.113.42/agent",
|
||||
"http://100.64.0.1/agent",
|
||||
"http://100.127.255.254:8000/a2a",
|
||||
"http://[fc00::1]/agent",
|
||||
"http://224.0.0.1/",
|
||||
} {
|
||||
if err := validateAgentURL(url); err == nil {
|
||||
t.Errorf("validateAgentURL(%q) in saasMode: got nil, want block", url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgentURL_StrictMode_BlocksRFC1918 is the strict-mode counterpart
|
||||
// to TestValidateAgentURL_SaaSMode_AllowsRFC1918.
|
||||
func TestValidateAgentURL_StrictMode_BlocksRFC1918(t *testing.T) {
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
for _, url := range []string{
|
||||
"http://10.1.2.3/agent",
|
||||
"http://172.16.0.1:8000/a2a",
|
||||
"http://172.31.44.78/agent",
|
||||
"http://192.168.1.100/agent",
|
||||
"http://[fd00::1]/agent",
|
||||
} {
|
||||
if err := validateAgentURL(url); err == nil {
|
||||
t.Errorf("validateAgentURL(%q) in strict mode: got nil, want block", url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgentURL_SaaSMode_LegacyOrgID covers the legacy MOLECULE_ORG_ID
|
||||
// signal (no MOLECULE_DEPLOY_MODE set) for validateAgentURL.
|
||||
func TestValidateAgentURL_SaaSMode_LegacyOrgID(t *testing.T) {
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "")
|
||||
t.Setenv("MOLECULE_ORG_ID", "7b2179dc-8cc6-4581-a3c6-c8bff4481086")
|
||||
for _, url := range []string{
|
||||
"http://10.1.2.3/agent",
|
||||
"http://172.18.0.42:8000/a2a",
|
||||
"http://192.168.1.100/agent",
|
||||
"http://[fd00::1]/agent",
|
||||
} {
|
||||
if err := validateAgentURL(url); err != nil {
|
||||
t.Errorf("validateAgentURL(%q) with legacy MOLECULE_ORG_ID: got %v, want nil", url, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== C18 — Register ownership ====================
|
||||
|
||||
// TestRegister_C18_BootstrapAllowedNoTokens verifies that a workspace with NO
|
||||
|
||||
@ -81,10 +81,23 @@ func resolveRestartTemplate(configsDir, wsName, dbRuntime string, body restartTe
|
||||
// Use case: Canvas Config tab changed the runtime; we need the new
|
||||
// runtime's base files (entry point, Dockerfile, skill scaffolding)
|
||||
// because the existing volume was written by the old runtime.
|
||||
//
|
||||
// SECURITY (CWE-22 / F1502): dbRuntime comes from the workspaces DB
|
||||
// column — set by the PATCH Update handler which only validates length
|
||||
// and newlines, not path-traversal characters. Without sanitisation an
|
||||
// attacker who holds a workspace token could set runtime to
|
||||
// "../../../etc" and, if a directory matching that path existed on the
|
||||
// host, load an arbitrary host directory as the workspace template.
|
||||
//
|
||||
// sanitizeRuntime applies an allowlist of known runtimes; any unknown
|
||||
// value (including traversal strings) is remapped to "langgraph". The
|
||||
// attacker cannot choose an arbitrary host path — they can at most
|
||||
// trigger application of the langgraph-default template.
|
||||
if body.ApplyTemplate && dbRuntime != "" {
|
||||
runtimeTemplate := filepath.Join(configsDir, dbRuntime+"-default")
|
||||
safeRuntime := sanitizeRuntime(dbRuntime)
|
||||
runtimeTemplate := filepath.Join(configsDir, safeRuntime+"-default")
|
||||
if _, err := os.Stat(runtimeTemplate); err == nil {
|
||||
label := dbRuntime + "-default"
|
||||
label := safeRuntime + "-default"
|
||||
log.Printf("Restart: applying template %s (runtime change)", label)
|
||||
return runtimeTemplate, label
|
||||
}
|
||||
|
||||
@ -176,3 +176,68 @@ func TestResolveRestartTemplate_Priority_ExplicitBeatsApplyTemplate(t *testing.T
|
||||
t.Errorf("expected path %q, got %q", expected, path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveRestartTemplate_CWE22_TraversalRuntime_FallsThrough is the
|
||||
// regression test for CWE-22 in Tier 4 of resolveRestartTemplate.
|
||||
//
|
||||
// An attacker who holds a workspace token can set the runtime field to a
|
||||
// path-traversal string (e.g. "../../../etc"). Before the fix, the code
|
||||
// did:
|
||||
// runtimeTemplate := filepath.Join(configsDir, dbRuntime+"-default")
|
||||
// which on a host with /configs/../../../etc-default would return /etc-default,
|
||||
// injecting arbitrary host files into the workspace container.
|
||||
//
|
||||
// After the fix, sanitizeRuntime is called first. Unknown runtimes
|
||||
// (including traversal strings) are remapped to "langgraph". The attacker
|
||||
// cannot choose an arbitrary host path — they can at most trigger
|
||||
// langgraph-default if that template happens to exist.
|
||||
//
|
||||
// This test verifies that a traversal string in dbRuntime falls through to
|
||||
// "existing-volume" when no langgraph-default template is present.
|
||||
func TestResolveRestartTemplate_CWE22_TraversalRuntime_FallsThrough(t *testing.T) {
|
||||
root := newTemplateDir(t) // no template dirs at all
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
dbRuntime string
|
||||
}{
|
||||
{"simple traversal", "../../../etc"},
|
||||
{"mid-path traversal", "langgraph/../../../etc"},
|
||||
{"absolute-path attempt", "/etc/passwd"},
|
||||
{"double-dot chain", "../.."},
|
||||
{"deep traversal", "a/b/c/../../../d"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path, label := resolveRestartTemplate(root, "Some Workspace", tc.dbRuntime, restartTemplateInput{
|
||||
ApplyTemplate: true,
|
||||
})
|
||||
// Must NOT return a path that escapes root
|
||||
if path != "" {
|
||||
t.Errorf("CWE-22: traversal runtime %q must not resolve; got path=%q", tc.dbRuntime, path)
|
||||
}
|
||||
if label != "existing-volume" {
|
||||
t.Errorf("CWE-22: traversal runtime %q must fall through to existing-volume; got label=%q", tc.dbRuntime, label)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveRestartTemplate_CWE22_TraversalRuntime_CannotOverrideKnownRuntime
|
||||
// verifies that even if a langgraph-default template exists, a traversal
|
||||
// string in dbRuntime resolves langgraph-default (the safe default) rather
|
||||
// than any attacker-chosen path. The attacker gains no additional access.
|
||||
func TestResolveRestartTemplate_CWE22_TraversalRuntime_CannotOverrideKnownRuntime(t *testing.T) {
|
||||
root := newTemplateDir(t, "langgraph-default")
|
||||
|
||||
path, label := resolveRestartTemplate(root, "Some Workspace", "../../../etc", restartTemplateInput{
|
||||
ApplyTemplate: true,
|
||||
})
|
||||
// Must resolve to langgraph-default, not to an escaped path
|
||||
expected := filepath.Join(root, "langgraph-default")
|
||||
if path != expected {
|
||||
t.Errorf("traversal runtime must resolve to langgraph-default; got path=%q", path)
|
||||
}
|
||||
if label != "langgraph-default" {
|
||||
t.Errorf("label must be langgraph-default; got %q", label)
|
||||
}
|
||||
}
|
||||
|
||||
@ -326,4 +326,101 @@ func TestDevModeAllowsLoopback_Predicate(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsSafeURL_SaaSMode_AllowsRFC1918 is the integration-level wrapper test
|
||||
// for the SaaS-mode SSRF relaxation. It exercises isSafeURL (the public API),
|
||||
// not isPrivateOrMetadataIP (the inner helper), ensuring the wrapper correctly
|
||||
// propagates saasMode() to its helper.
|
||||
//
|
||||
// Regression guard: isSafeURL previously hardcoded RFC-1918 rejection and never
|
||||
// called saasMode(), causing 502 on every A2A call from Docker-networked or VPC
|
||||
// deployments (issue #1785 / PR #1785). The inner helper's TestIsPrivateOrMetadataIP_SaaSMode
|
||||
// was green the whole time — classic "test the intent, not the integration" gap.
|
||||
func TestIsSafeURL_SaaSMode_AllowsRFC1918(t *testing.T) {
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "saas")
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
for _, url := range []string{
|
||||
"http://10.1.2.3/agent",
|
||||
"http://10.0.0.5:8000/a2a",
|
||||
"http://172.16.0.1/agent",
|
||||
"http://172.18.0.42:8000/a2a",
|
||||
"http://172.31.44.78/agent",
|
||||
"http://192.168.1.100/agent",
|
||||
"http://192.168.255.254:9000/a2a",
|
||||
"http://[fd00::1]/agent",
|
||||
"http://[fd12:3456:789a::42]/a2a",
|
||||
} {
|
||||
if err := isSafeURL(url); err != nil {
|
||||
t.Errorf("isSafeURL(%q) in saasMode: got %v, want nil", url, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsSafeURL_SaaSMode_StillBlocksMetadataEtAl verifies that even in SaaS
|
||||
// mode the always-blocked ranges (metadata, loopback, TEST-NET, CGNAT) stay blocked.
|
||||
func TestIsSafeURL_SaaSMode_StillBlocksMetadataEtAl(t *testing.T) {
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "saas")
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
for _, url := range []string{
|
||||
// Cloud metadata — must stay blocked in every mode.
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://169.254.0.1/",
|
||||
// Loopback — must stay blocked.
|
||||
"http://127.0.0.1:8080",
|
||||
"http://[::1]:8080",
|
||||
// TEST-NET documentation ranges — must stay blocked.
|
||||
"http://192.0.2.5/agent",
|
||||
"http://198.51.100.5/a2a",
|
||||
"http://203.0.113.42/agent",
|
||||
// CGNAT — must stay blocked.
|
||||
"http://100.64.0.1/agent",
|
||||
"http://100.127.255.254:8000/a2a",
|
||||
// ULA fc00::/8 (non-fd00 half) — must stay blocked in SaaS.
|
||||
"http://[fc00::1]/agent",
|
||||
// Non-RFC-1918 private ranges still blocked.
|
||||
"http://224.0.0.1/",
|
||||
} {
|
||||
if err := isSafeURL(url); err == nil {
|
||||
t.Errorf("isSafeURL(%q) in saasMode: got nil, want block", url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsSafeURL_StrictMode_BlocksRFC1918 is the strict-mode counterpart to
|
||||
// TestIsSafeURL_SaaSMode_AllowsRFC1918. In self-hosted / single-container
|
||||
// deployments there is no legitimate reason to reach RFC-1918 agents, so the
|
||||
// wrapper must block them.
|
||||
func TestIsSafeURL_StrictMode_BlocksRFC1918(t *testing.T) {
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
for _, url := range []string{
|
||||
"http://10.1.2.3/agent",
|
||||
"http://172.16.0.1:8000/a2a",
|
||||
"http://172.31.44.78/agent",
|
||||
"http://192.168.1.100/agent",
|
||||
"http://[fd00::1]/agent",
|
||||
} {
|
||||
if err := isSafeURL(url); err == nil {
|
||||
t.Errorf("isSafeURL(%q) in strict mode: got nil, want block", url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsSafeURL_SaasMode_LegacyOrgID covers the legacy MOLECULE_ORG_ID signal
|
||||
// (no MOLECULE_DEPLOY_MODE set). An org ID alone is sufficient to activate SaaS
|
||||
// mode per the saasMode() resolution ladder.
|
||||
func TestIsSafeURL_SaasMode_LegacyOrgID(t *testing.T) {
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "")
|
||||
t.Setenv("MOLECULE_ORG_ID", "7b2179dc-8cc6-4581-a3c6-c8bff4481086")
|
||||
for _, url := range []string{
|
||||
"http://10.1.2.3/agent",
|
||||
"http://172.18.0.42:8000/a2a",
|
||||
"http://192.168.1.100/agent",
|
||||
"http://[fd00::1]/agent",
|
||||
} {
|
||||
if err := isSafeURL(url); err != nil {
|
||||
t.Errorf("isSafeURL(%q) with legacy MOLECULE_ORG_ID: got %v, want nil", url, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -410,9 +410,13 @@ func (h *TemplatesHandler) WriteFile(c *gin.Context) {
|
||||
func (h *TemplatesHandler) DeleteFile(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
filePath := c.Param("path")
|
||||
if strings.HasPrefix(filePath, "/") {
|
||||
filePath = filePath[1:]
|
||||
// Reject absolute paths before stripping the leading slash — this check
|
||||
// must come before the strip so that "/etc/passwd" is not silently accepted.
|
||||
if filepath.IsAbs(filePath) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "absolute paths not permitted"})
|
||||
return
|
||||
}
|
||||
filePath = strings.TrimPrefix(filePath, "/")
|
||||
|
||||
if err := validateRelPath(filePath); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||
@ -428,8 +432,11 @@ func (h *TemplatesHandler) DeleteFile(c *gin.Context) {
|
||||
|
||||
// Delete via docker exec when container is running
|
||||
if containerName := h.findContainer(ctx, workspaceID); containerName != "" {
|
||||
containerPath := "/configs/" + filePath
|
||||
_, err := h.execInContainer(ctx, containerName, []string{"rm", "-rf", containerPath})
|
||||
// CWE-78: use filepath.Join instead of string concat to prevent path
|
||||
// injection into the exec argument. validateRelPath above is the primary
|
||||
// guard; filepath.Join is defence-in-depth. Use -f (not -rf) to avoid
|
||||
// recursive deletion of an entire directory via traversal.
|
||||
_, err := h.execInContainer(ctx, containerName, []string{"rm", "-f", filepath.Join("/configs", filePath)})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to delete: %v", err)})
|
||||
return
|
||||
@ -485,7 +492,11 @@ func (h *TemplatesHandler) SharedContext(c *gin.Context) {
|
||||
if err := validateRelPath(relPath); err != nil {
|
||||
continue
|
||||
}
|
||||
content, err := h.execInContainer(ctx, containerName, []string{"cat", "/configs/" + relPath})
|
||||
// CWE-78: pass path components as separate exec args instead of
|
||||
// concatenating into a single string. validateRelPath above is the
|
||||
// primary guard; separate args is defence-in-depth (no shell
|
||||
// interpolation possible in exec form).
|
||||
content, err := h.execInContainer(ctx, containerName, []string{"cat", "/configs", relPath})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -649,7 +649,7 @@ func TestDeleteFile_WorkspaceNotFound(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{
|
||||
{Key: "id", Value: "ws-del-nf"},
|
||||
{Key: "path", Value: "/old-file.txt"},
|
||||
{Key: "path", Value: "old-file.txt"},
|
||||
}
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-del-nf/files/old-file.txt", nil)
|
||||
|
||||
@ -789,3 +789,107 @@ func TestResolveTemplateDir_NotFound(t *testing.T) {
|
||||
t.Errorf("expected empty string, got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== CWE-78 hardening regression (issue #2011) ====================
|
||||
// These tests lock in the defence-in-depth guards for DeleteFile and SharedContext.
|
||||
// The primary guard is validateRelPath (fires before any exec/file-read path);
|
||||
// the exec-form path construction (filepath.Join / separate args) is defence-in-depth.
|
||||
|
||||
// TestCWE78_DeleteFile_TraversalVariants asserts that a range of traversal patterns
|
||||
// are all rejected with 400 before any Docker exec or ephemeral container operation.
|
||||
// This covers the validateRelPath guard that sits at the entry of DeleteFile.
|
||||
func TestCWE78_DeleteFile_TraversalVariants(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"double dotdot", "/../../../etc/passwd"},
|
||||
{"leading dotdot", "/../secret"},
|
||||
{"mid-path traversal", "/valid/../../../etc/shadow"},
|
||||
{"absolute path", "/etc/passwd"},
|
||||
{"encoded dotdot raw", "..%2F..%2Fetc%2Fpasswd"},
|
||||
{"triple dotdot", "/../../.."},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
handler := NewTemplatesHandler(t.TempDir(), nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{
|
||||
{Key: "id", Value: "ws-cwe78"},
|
||||
{Key: "path", Value: tc.path},
|
||||
}
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-cwe78/files"+tc.path, nil)
|
||||
|
||||
handler.DeleteFile(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("path %q: expected 400 (traversal blocked), got %d: %s",
|
||||
tc.path, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCWE78_SharedContext_SkipsTraversalPaths asserts that when a workspace's
|
||||
// config.yaml lists traversal paths in shared_context, SharedContext skips them
|
||||
// via validateRelPath rather than passing them to exec or os.ReadFile.
|
||||
// Uses the filesystem fallback path (no docker client) so no container mock needed.
|
||||
func TestCWE78_SharedContext_SkipsTraversalPaths(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
// Create a template directory that SharedContext will resolve for "Cwe Agent".
|
||||
tmplDir := filepath.Join(tmpDir, "cwe-agent")
|
||||
os.MkdirAll(tmplDir, 0755)
|
||||
// config.yaml with a mix of safe and traversal-attack paths.
|
||||
configYAML := "name: Cwe Agent\nshared_context:\n - safe-file.md\n - ../../etc/passwd\n - ../shadow\n - another-safe.md\n"
|
||||
os.WriteFile(filepath.Join(tmplDir, "config.yaml"), []byte(configYAML), 0644)
|
||||
// Only write the safe files — traversal paths must not be reachable.
|
||||
os.WriteFile(filepath.Join(tmplDir, "safe-file.md"), []byte("# safe"), 0644)
|
||||
os.WriteFile(filepath.Join(tmplDir, "another-safe.md"), []byte("# also safe"), 0644)
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces WHERE id =").
|
||||
WithArgs("ws-cwe78-sc").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Cwe Agent"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-cwe78-sc"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-cwe78-sc/shared-context", nil)
|
||||
|
||||
handler := NewTemplatesHandler(tmpDir, nil) // nil docker → filesystem fallback
|
||||
handler.SharedContext(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var files []struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &files); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
// Only the two safe files must appear; traversal paths must be absent.
|
||||
if len(files) != 2 {
|
||||
t.Errorf("expected 2 safe files, got %d: %v", len(files), files)
|
||||
}
|
||||
for _, f := range files {
|
||||
if strings.Contains(f.Path, "..") || strings.Contains(f.Path, "etc") || strings.Contains(f.Path, "shadow") {
|
||||
t.Errorf("traversal path %q must not appear in shared-context response", f.Path)
|
||||
}
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -77,17 +77,26 @@ func (h *TerminalHandler) HandleConnect(c *gin.Context) {
|
||||
// A2A message-passing, so we apply the same hierarchy check here.
|
||||
// GH#756/#1609 security fix: if the caller claims a specific workspace
|
||||
// identity (X-Workspace-ID header), the bearer token — if present — must
|
||||
// belong to that claimed workspace. ValidateAnyToken accepted ANY valid org
|
||||
// token, allowing Workspace A to forge X-Workspace-ID: B and reach B's
|
||||
// terminal if A held any valid token. ValidateToken binds the token to
|
||||
// the claimed workspace identity.
|
||||
// belong to that claimed workspace. Previously ValidateAnyToken accepted
|
||||
// ANY valid org token, allowing Workspace A to forge X-Workspace-ID: B
|
||||
// and reach B's terminal if A held any valid token. ValidateToken binds
|
||||
// the workspace-scoped token to the claimed workspace identity. Org-level
|
||||
// tokens are handled separately via the org_token_id context key.
|
||||
callerID := c.GetHeader("X-Workspace-ID")
|
||||
if callerID != "" && callerID != workspaceID {
|
||||
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
|
||||
if tok != "" {
|
||||
if err := wsauth.ValidateToken(ctx, db.DB, callerID, tok); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token for claimed workspace"})
|
||||
return
|
||||
// Org-scoped tokens (org_api_tokens) are validated at the org level
|
||||
// by WorkspaceAuth and do not have a workspace_auth_tokens row, so
|
||||
// ValidateToken always returns ErrInvalidToken for them. If WorkspaceAuth
|
||||
// already validated an org token (org_token_id set in context), trust
|
||||
// the X-Workspace-ID claim — the hierarchy is enforced by
|
||||
// canCommunicateCheck below. Reject everything else.
|
||||
if c.GetString("org_token_id") == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token for claimed workspace"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if !canCommunicateCheck(callerID, workspaceID) {
|
||||
|
||||
@ -455,3 +455,38 @@ func TestTerminalConnect_KI005_AllowsSiblingWorkspace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestKI005_OrgToken_SkipsValidateToken verifies that when WorkspaceAuth already
|
||||
// validated an org token (org_token_id set in gin context), the X-Workspace-ID
|
||||
// claim is trusted without a workspace_auth_tokens lookup. The hierarchy is still
|
||||
// enforced by canCommunicateCheck. Regression guard for the A2A routing regression
|
||||
// introduced in GH#1885: internal routing uses org tokens which are not in
|
||||
// workspace_auth_tokens, so ValidateToken would always fail for them.
|
||||
func TestKI005_OrgToken_SkipsValidateToken(t *testing.T) {
|
||||
setupTestDB(t) // no ValidateToken ExpectQuery — none should fire
|
||||
prev := canCommunicateCheck
|
||||
canCommunicateCheck = func(callerID, targetID string) bool {
|
||||
// Simulate platform agent → target workspace (same org).
|
||||
return callerID == "ws-platform" && targetID == "ws-target"
|
||||
}
|
||||
defer func() { canCommunicateCheck = prev }()
|
||||
|
||||
h := NewTerminalHandler(nil)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-target"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-target/terminal", nil)
|
||||
c.Request.Header.Set("X-Workspace-ID", "ws-platform")
|
||||
c.Request.Header.Set("Authorization", "Bearer org-token-abc123")
|
||||
// Simulate WorkspaceAuth having validated the org token (orgtoken.Validate
|
||||
// succeeded). HandleConnect must skip ValidateToken and trust the claim.
|
||||
c.Set("org_token_id", "tok-org-abc")
|
||||
|
||||
h.HandleConnect(c)
|
||||
|
||||
// Org token path: ValidateToken skipped → canCommunicateCheck=true →
|
||||
// falls through to Docker path → 503 nil-docker (no Docker client).
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("org-token A2A: got %d, want 503 nil-docker (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -96,6 +96,14 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri
|
||||
applyAgentGitIdentity(envVars, payload.Name)
|
||||
applyRuntimeModelEnv(envVars, payload.Runtime, payload.Model)
|
||||
|
||||
// Propagate the workspace's role into env so role-aware plugins
|
||||
// (gh-identity — molecule-core#1957) can read it without the
|
||||
// plugin interface having to carry the full payload. Role is
|
||||
// cosmetic metadata — no auth weight on it — safe to surface as env.
|
||||
if payload.Role != "" {
|
||||
envVars["MOLECULE_AGENT_ROLE"] = payload.Role
|
||||
}
|
||||
|
||||
// Plugin extension point: run any registered EnvMutators (e.g.
|
||||
// github-app-auth, vault-secrets) AFTER built-in identity injection so
|
||||
// plugins can override or augment GIT_AUTHOR_*, GITHUB_TOKEN, etc.
|
||||
@ -702,6 +710,11 @@ func (h *WorkspaceHandler) provisionWorkspaceCP(workspaceID, templatePath string
|
||||
|
||||
applyAgentGitIdentity(envVars, payload.Name)
|
||||
applyRuntimeModelEnv(envVars, payload.Runtime, payload.Model)
|
||||
// Propagate role for role-aware plugins (#1957). See provisionWorkspace
|
||||
// above for rationale.
|
||||
if payload.Role != "" {
|
||||
envVars["MOLECULE_AGENT_ROLE"] = payload.Role
|
||||
}
|
||||
if err := h.envMutators.Run(ctx, workspaceID, envVars); err != nil {
|
||||
log.Printf("CPProvisioner: env mutator failed for %s: %v", workspaceID, err)
|
||||
// F1086 / #1206: env mutator errors (missing tokens, vault paths) must not
|
||||
|
||||
@ -304,6 +304,7 @@ func CanvasOrBearer(database *sql.DB) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "admin auth required"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -11,10 +11,10 @@ import (
|
||||
)
|
||||
|
||||
// orgTokenValidateQuery is matched for orgtoken.Validate in both
|
||||
// WorkspaceAuth and AdminAuth middleware paths. Post-migration 036 the
|
||||
// query selects id, prefix, AND org_id in a single round-trip; the
|
||||
// secondary "SELECT org_id::text FROM org_api_tokens WHERE id" hop is
|
||||
// gone, so tests do not need to stub it.
|
||||
// WorkspaceAuth and AdminAuth middleware paths. The query selects
|
||||
// id, prefix, org_id from org_api_tokens where token_hash matches and
|
||||
// revoked_at IS NULL. The org_id is returned directly from the primary
|
||||
// query — no secondary lookup is needed.
|
||||
const orgTokenValidateQuery = "SELECT id, prefix, org_id FROM org_api_tokens WHERE token_hash"
|
||||
|
||||
func TestWorkspaceAuth_ValidOrgToken_SetsOrgIDContext(t *testing.T) {
|
||||
@ -30,12 +30,17 @@ func TestWorkspaceAuth_ValidOrgToken_SetsOrgIDContext(t *testing.T) {
|
||||
orgToken := "tok_test_org_token_abc123"
|
||||
tokenHash := sha256.Sum256([]byte(orgToken))
|
||||
|
||||
// Single-round-trip Validate: id + prefix + org_id.
|
||||
// orgtoken.Validate — returns id + prefix + org_id directly.
|
||||
mock.ExpectQuery(orgTokenValidateQuery).
|
||||
WithArgs(tokenHash[:]).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "org_id"}).
|
||||
AddRow("tok-org-abc", "tok_test", "00000000-0000-0000-0000-000000000001"))
|
||||
|
||||
// Best-effort last_used_at update after Validate succeeds.
|
||||
mock.ExpectExec("UPDATE org_api_tokens SET last_used_at").
|
||||
WithArgs("tok-org-abc").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/workspaces/:id/secrets", WorkspaceAuth(mockDB), func(c *gin.Context) {
|
||||
v, exists := c.Get("org_id")
|
||||
@ -78,13 +83,17 @@ func TestWorkspaceAuth_ValidOrgToken_OrgIDNULL_DoesNotSetContext(t *testing.T) {
|
||||
orgToken := "tok_old_token_no_org"
|
||||
tokenHash := sha256.Sum256([]byte(orgToken))
|
||||
|
||||
// Single-round-trip Validate; NULL org_id row mimics a pre-migration
|
||||
// token. Middleware must NOT set the org_id context key in this case.
|
||||
// orgtoken.Validate — org_id NULL, so no org_id context key is set.
|
||||
mock.ExpectQuery(orgTokenValidateQuery).
|
||||
WithArgs(tokenHash[:]).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "org_id"}).
|
||||
AddRow("tok-old-xyz", "tok_old_", nil))
|
||||
|
||||
// Best-effort last_used_at update after Validate succeeds (even for NULL org_id).
|
||||
mock.ExpectExec("UPDATE org_api_tokens SET last_used_at").
|
||||
WithArgs("tok-old-xyz").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/workspaces/:id/secrets", WorkspaceAuth(mockDB), func(c *gin.Context) {
|
||||
_, exists := c.Get("org_id")
|
||||
@ -125,7 +134,7 @@ func TestAdminAuth_ValidOrgToken_SetsOrgIDContext(t *testing.T) {
|
||||
mock.ExpectQuery(hasAnyLiveTokenGlobalQuery).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
|
||||
// Single-round-trip Validate via AdminAuth: id + prefix + org_id.
|
||||
// orgtoken.Validate via AdminAuth — returns id + prefix + org_id directly.
|
||||
mock.ExpectQuery(orgTokenValidateQuery).
|
||||
WithArgs(tokenHash[:]).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "org_id"}).
|
||||
@ -171,7 +180,6 @@ func TestAdminAuth_ValidOrgToken_OrgIDNULL_DoesNotSetContext(t *testing.T) {
|
||||
mock.ExpectQuery(hasAnyLiveTokenGlobalQuery).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
|
||||
// Single-round-trip Validate with NULL org_id — AdminAuth path.
|
||||
mock.ExpectQuery(orgTokenValidateQuery).
|
||||
WithArgs(tokenHash[:]).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "org_id"}).
|
||||
@ -200,9 +208,9 @@ func TestAdminAuth_ValidOrgToken_OrgIDNULL_DoesNotSetContext(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWorkspaceAuth_OrgToken_DBRowScanError_DoesNotPanic(t *testing.T) {
|
||||
// F1097: if the org_id SELECT returns an unexpected column count or type,
|
||||
// the deferred suppress-pattern must not crash — the token is still valid,
|
||||
// org_id is simply not set (token is denied by requireCallerOwnsOrg at use-time).
|
||||
// F1097: org token validation must not panic if the org_id DB value is
|
||||
// unexpected — org_id is simply not set on context. Validate scans org_id as
|
||||
// sql.NullString and only sets it if .Valid is true.
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New: %v", err)
|
||||
@ -218,6 +226,11 @@ func TestWorkspaceAuth_OrgToken_DBRowScanError_DoesNotPanic(t *testing.T) {
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "org_id"}).
|
||||
AddRow("tok-ok", "tok_tok_", "00000000-0000-0000-0000-000000000099"))
|
||||
|
||||
// Best-effort last_used_at update after Validate succeeds.
|
||||
mock.ExpectExec("UPDATE org_api_tokens SET last_used_at").
|
||||
WithArgs("tok-ok").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/workspaces/:id/secrets", WorkspaceAuth(mockDB), func(c *gin.Context) {
|
||||
// org_id key may or may not be set — either is acceptable here.
|
||||
@ -257,6 +270,11 @@ func TestWorkspaceAuth_OrgToken_SetsAllContextKeys(t *testing.T) {
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "org_id"}).
|
||||
AddRow("tok-full", "tok_fu_", expectedOrgID))
|
||||
|
||||
// Best-effort last_used_at update after Validate succeeds.
|
||||
mock.ExpectExec("UPDATE org_api_tokens SET last_used_at").
|
||||
WithArgs("tok-full").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/workspaces/:id/secrets", WorkspaceAuth(mockDB), func(c *gin.Context) {
|
||||
id, ok := c.Get("org_token_id")
|
||||
|
||||
@ -523,11 +523,9 @@ func TestAdminAuth_OrgToken_SetsOrgID(t *testing.T) {
|
||||
mock.ExpectQuery(hasAnyLiveTokenGlobalQuery).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
|
||||
// Single-round-trip Validate: id + prefix + org_id. The
|
||||
// secondary org_id SELECT has been consolidated into this
|
||||
// query, so tt.orgIDFromDB goes into the same row instead of
|
||||
// being returned by a second ExpectQuery. Note: org tokens
|
||||
// are checked BEFORE the workspace token path
|
||||
// orgtoken.Validate: org token hash matches, returns id + prefix + org_id.
|
||||
// The org_id is returned directly from the primary query.
|
||||
// Note: org tokens are checked BEFORE the workspace token path
|
||||
// (ValidateAnyToken), so ValidateAnyToken is NOT called here.
|
||||
mock.ExpectQuery(orgTokenValidateQueryV1).
|
||||
WithArgs(orgTokenHash[:]).
|
||||
@ -1013,8 +1011,10 @@ func TestCanvasOrBearer_TokensExist_NoCreds_Returns401(t *testing.T) {
|
||||
mock.ExpectQuery(hasAnyLiveTokenGlobalQuery).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
|
||||
handlerCalled := false
|
||||
r := gin.New()
|
||||
r.PUT("/canvas/viewport", CanvasOrBearer(mockDB), func(c *gin.Context) {
|
||||
handlerCalled = true
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
@ -1025,6 +1025,47 @@ func TestCanvasOrBearer_TokensExist_NoCreds_Returns401(t *testing.T) {
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("no creds: got %d, want 401", w.Code)
|
||||
}
|
||||
if handlerCalled {
|
||||
t.Error("handler was called after AbortWithStatusJSON — missing return allows fall-through")
|
||||
}
|
||||
if body := w.Body.String(); body == `{"ok":true}` {
|
||||
t.Error("handler body written after AbortWithStatusJSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanvasOrBearer_TokensExist_WrongOrigin_Returns401(t *testing.T) {
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock: %v", err)
|
||||
}
|
||||
defer mockDB.Close()
|
||||
|
||||
mock.ExpectQuery(hasAnyLiveTokenGlobalQuery).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
|
||||
t.Setenv("CORS_ORIGINS", "https://acme.moleculesai.app")
|
||||
|
||||
handlerCalled := false
|
||||
r := gin.New()
|
||||
r.PUT("/canvas/viewport", CanvasOrBearer(mockDB), func(c *gin.Context) {
|
||||
handlerCalled = true
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPut, "/canvas/viewport", nil)
|
||||
req.Header.Set("Origin", "https://evil.example.com")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("wrong origin: got %d, want 401", w.Code)
|
||||
}
|
||||
if handlerCalled {
|
||||
t.Error("handler was called after AbortWithStatusJSON — missing return allows fall-through")
|
||||
}
|
||||
if body := w.Body.String(); body == `{"ok":true}` {
|
||||
t.Error("handler body written after AbortWithStatusJSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanvasOrBearer_TokensExist_CanvasOrigin_Passes(t *testing.T) {
|
||||
@ -1102,7 +1143,7 @@ func TestAdminAuth_RemovedWorkspaceToken_Returns401(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanvasOrBearer_TokensExist_WrongOrigin_Returns401(t *testing.T) {
|
||||
func TestCanvasOrBearer_WrongOrigin_Blocked(t *testing.T) {
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock: %v", err)
|
||||
|
||||
@ -72,10 +72,6 @@ func TestValidate_HappyPath(t *testing.T) {
|
||||
plaintext := "known-plaintext-for-test"
|
||||
hash := sha256.Sum256([]byte(plaintext))
|
||||
|
||||
// Migration 036 added org_id column; Validate now scans (id, prefix,
|
||||
// org_id) in one query. nil here models a pre-migration token
|
||||
// (org_id still NULL); Validate returns empty orgID and callers
|
||||
// treat the absence of an org binding as "no cross-org access".
|
||||
mock.ExpectQuery(`SELECT id, prefix, org_id FROM org_api_tokens`).
|
||||
WithArgs(hash[:]).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "org_id"}).AddRow("tok-live", "abcd1234", nil))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user