Merge pull request #485 from Molecule-AI/feat/mcp-docs-tokens-external-agent
feat(platform): token management API + MCP setup + external agent guide
This commit is contained in:
commit
ed3e8eed3c
7
.github/workflows/publish-canvas-image.yml
vendored
7
.github/workflows/publish-canvas-image.yml
vendored
@ -71,11 +71,8 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Compute tags
|
||||
id: tags
|
||||
|
||||
20
.github/workflows/publish-platform-image.yml
vendored
20
.github/workflows/publish-platform-image.yml
vendored
@ -88,24 +88,12 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Log in to Fly registry
|
||||
# username MUST be literal "x". Fly's registry returns 401 for any
|
||||
# other value (verified locally 2026-04-15 — "molecule-ai" fails,
|
||||
# "x" succeeds with the same token). The password is the FLY_API_TOKEN.
|
||||
# Rotation: see docs/runbooks/saas-secrets.md — FLY_API_TOKEN lives in
|
||||
# two places (GitHub Actions secret here + `fly secrets` on molecule-cp)
|
||||
# and MUST be updated in both on rotation.
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.fly.io
|
||||
username: x
|
||||
password: ${{ secrets.FLY_API_TOKEN }}
|
||||
shell: bash
|
||||
run: echo "${{ secrets.FLY_API_TOKEN }}" | docker login registry.fly.io -u x --password-stdin
|
||||
|
||||
- name: Compute tags
|
||||
id: tags
|
||||
|
||||
@ -11,8 +11,8 @@
|
||||
},
|
||||
"molecule": {
|
||||
"type": "stdio",
|
||||
"command": "node",
|
||||
"args": ["./mcp-server/dist/index.js"],
|
||||
"command": "npx",
|
||||
"args": ["-y", "@molecule-ai/mcp-server"],
|
||||
"env": {
|
||||
"MOLECULE_URL": "http://localhost:8080"
|
||||
}
|
||||
|
||||
@ -6,7 +6,18 @@ interface Props {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
const WS_URL = (process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8080").replace("/ws", "");
|
||||
// Derive base WebSocket URL (without /ws path) for terminal connections.
|
||||
const WS_URL = (() => {
|
||||
const explicit = process.env.NEXT_PUBLIC_WS_URL;
|
||||
if (explicit) return explicit.replace("/ws", "");
|
||||
const platform = process.env.NEXT_PUBLIC_PLATFORM_URL;
|
||||
if (platform) return platform.replace(/^http/, "ws");
|
||||
if (typeof window !== "undefined") {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${proto}//${window.location.host}`;
|
||||
}
|
||||
return "ws://localhost:8080";
|
||||
})();
|
||||
|
||||
export function TerminalTab({ workspaceId }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -1,10 +1,27 @@
|
||||
import { useCanvasStore } from "./canvas";
|
||||
|
||||
export const WS_URL =
|
||||
process.env.NEXT_PUBLIC_WS_URL ??
|
||||
(process.env.NEXT_PUBLIC_PLATFORM_URL ?? "http://localhost:8080")
|
||||
.replace(/^http/, "ws")
|
||||
.concat("/ws");
|
||||
// Derive WebSocket URL. Priority:
|
||||
// 1. Explicit NEXT_PUBLIC_WS_URL (non-empty)
|
||||
// 2. Derived from NEXT_PUBLIC_PLATFORM_URL (http→ws + /ws)
|
||||
// 3. Derived from window.location (for same-origin tenant image)
|
||||
// 4. Fallback to localhost
|
||||
function deriveWsUrl(): string {
|
||||
const explicit = process.env.NEXT_PUBLIC_WS_URL;
|
||||
if (explicit) return explicit;
|
||||
|
||||
const platform = process.env.NEXT_PUBLIC_PLATFORM_URL;
|
||||
if (platform) return platform.replace(/^http/, "ws").concat("/ws");
|
||||
|
||||
// Same-origin tenant: derive from browser location
|
||||
if (typeof window !== "undefined") {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${proto}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
return "ws://localhost:8080/ws";
|
||||
}
|
||||
|
||||
export const WS_URL = deriveWsUrl();
|
||||
|
||||
export interface WSMessage {
|
||||
event: string;
|
||||
|
||||
784
docs/guides/external-agent-registration.md
Normal file
784
docs/guides/external-agent-registration.md
Normal file
@ -0,0 +1,784 @@
|
||||
# External Agent Registration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
An **external agent** (also called a remote agent) is any AI agent that runs
|
||||
outside the Molecule AI platform's Docker network but participates in the
|
||||
canvas, communicates with other agents via the A2A protocol, and is managed as
|
||||
a first-class workspace.
|
||||
|
||||
Use cases for external agents:
|
||||
|
||||
- **Existing infrastructure** -- you already run an agent on your own servers
|
||||
and want it to join a Molecule org without re-deploying inside Docker.
|
||||
- **Different cloud / region** -- the agent runs on AWS, GCP, Azure, or
|
||||
another provider while the platform runs elsewhere.
|
||||
- **Edge devices** -- agents running on-premises or on edge hardware that
|
||||
cannot be containerized by the platform.
|
||||
- **Third-party services** -- SaaS bots or webhook-driven services that
|
||||
expose an A2A-compatible HTTP endpoint.
|
||||
- **Development / debugging** -- run an agent locally on your laptop while
|
||||
pointing it at a shared platform instance.
|
||||
|
||||
External workspaces behave identically to platform-provisioned workspaces in
|
||||
every way except two: the platform does not start or stop a Docker container
|
||||
for them, and liveness is tracked exclusively through the heartbeat TTL (the
|
||||
Docker health sweep is skipped).
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Details |
|
||||
|-------------|---------|
|
||||
| Running Molecule AI platform | Default `http://localhost:8080`. Set `NEXT_PUBLIC_PLATFORM_URL` in canvas accordingly. |
|
||||
| Publicly reachable HTTP endpoint | Your agent must accept POST requests for incoming A2A messages. If you are behind NAT, use a tunnel (ngrok, Cloudflare Tunnel, etc.). |
|
||||
| Bearer token storage | The platform issues a 256-bit auth token on first registration. You must persist it -- it is shown only once and cannot be recovered. |
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Registration
|
||||
|
||||
### 1. Create an External Workspace
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/workspaces \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <admin-token>" \
|
||||
-d '{
|
||||
"name": "My External Agent",
|
||||
"role": "researcher",
|
||||
"runtime": "external",
|
||||
"external": true,
|
||||
"url": "https://my-agent.example.com",
|
||||
"tier": 2,
|
||||
"parent_id": null
|
||||
}'
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "a1b2c3d4-...",
|
||||
"status": "online",
|
||||
"external": true
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `POST /workspaces` requires `AdminAuth` (bearer token) when any live admin
|
||||
token exists on the platform. On a fresh install with no tokens, it
|
||||
bootstraps open.
|
||||
- `external: true` tells the platform to skip Docker provisioning. The
|
||||
workspace goes straight to `online` if a URL is provided.
|
||||
- `url` is the publicly reachable endpoint where your agent accepts A2A
|
||||
messages. It must be an HTTPS or HTTP URL.
|
||||
- `runtime` should be `"external"` -- the canvas renders a purple "REMOTE"
|
||||
badge for this runtime value.
|
||||
- `tier` defaults to `1` if omitted. Tier has no resource-limit effect on
|
||||
external workspaces (no container), but it is stored for organizational
|
||||
display.
|
||||
- `parent_id` is optional. Set it to nest this workspace under an existing
|
||||
team/parent workspace.
|
||||
|
||||
Save the returned `id` -- you will need it for every subsequent call.
|
||||
|
||||
### 2. Register with the Platform
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/registry/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "<workspace-id-from-step-1>",
|
||||
"url": "https://my-agent.example.com",
|
||||
"agent_card": {
|
||||
"name": "My External Agent",
|
||||
"description": "Handles research tasks and summarization",
|
||||
"skills": ["research", "summarization", "analysis"],
|
||||
"runtime": "external"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**Response (first registration):**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "registered",
|
||||
"auth_token": "mol_abc123...very-long-token"
|
||||
}
|
||||
```
|
||||
|
||||
**Critical:** The `auth_token` field is present **only on first
|
||||
registration**. It is never returned again. Store it securely (environment
|
||||
variable, secrets manager, etc.). All subsequent authenticated calls require
|
||||
this token.
|
||||
|
||||
On re-registration (e.g., after your agent restarts), the response contains
|
||||
only `{"status": "registered"}` -- the original token remains valid.
|
||||
|
||||
### 3. Start the Heartbeat Loop
|
||||
|
||||
Your agent must send a heartbeat every **30 seconds** to stay online. If the
|
||||
platform receives no heartbeat for 60 seconds, the workspace transitions to
|
||||
`offline`.
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/registry/heartbeat \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <auth_token>" \
|
||||
-d '{
|
||||
"workspace_id": "<workspace-id>",
|
||||
"error_rate": 0.0,
|
||||
"active_tasks": 0,
|
||||
"current_task": "",
|
||||
"uptime_seconds": 3600,
|
||||
"sample_error": ""
|
||||
}'
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
Heartbeat fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `workspace_id` | string | Required. Your workspace ID. |
|
||||
| `error_rate` | float | 0.0 -- 1.0. If > 0.5, workspace enters `degraded` status on the canvas. |
|
||||
| `active_tasks` | int | Number of tasks currently running. Displayed in the canvas node. |
|
||||
| `current_task` | string | Human-readable description of current work. Shown in the workspace detail panel. |
|
||||
| `uptime_seconds` | int | Seconds since your agent started. |
|
||||
| `sample_error` | string | Most recent error message, if any. Visible in monitoring. |
|
||||
|
||||
### 4. Handle Incoming A2A Messages
|
||||
|
||||
Your agent must accept `POST` requests at the URL you registered. The
|
||||
platform (and other agents) send messages in A2A JSON-RPC format:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"message": {
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{ "type": "text", "text": "Research the latest trends in AI safety" }
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"source": "agent",
|
||||
"history": []
|
||||
}
|
||||
},
|
||||
"id": "req-abc-123"
|
||||
}
|
||||
```
|
||||
|
||||
Your endpoint must return a JSON-RPC response:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"message": {
|
||||
"role": "agent",
|
||||
"parts": [
|
||||
{ "type": "text", "text": "Here are the latest AI safety trends..." }
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "req-abc-123"
|
||||
}
|
||||
```
|
||||
|
||||
For errors, return a JSON-RPC error object:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"error": {
|
||||
"code": -32603,
|
||||
"message": "Internal error: model rate limited"
|
||||
},
|
||||
"id": "req-abc-123"
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Send Messages to Other Agents
|
||||
|
||||
Use the A2A proxy to communicate with any workspace your agent is allowed to
|
||||
reach:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/workspaces/<target-workspace-id>/a2a \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <auth_token>" \
|
||||
-H "X-Workspace-ID: <your-workspace-id>" \
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"message": {
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{ "type": "text", "text": "Can you help with this task?" }
|
||||
]
|
||||
},
|
||||
"metadata": {}
|
||||
},
|
||||
"id": "req-456"
|
||||
}'
|
||||
```
|
||||
|
||||
Both headers are required:
|
||||
|
||||
- `Authorization: Bearer <token>` -- your workspace auth token.
|
||||
- `X-Workspace-ID: <your-id>` -- identifies which workspace is making the
|
||||
call. The platform uses this to enforce communication rules.
|
||||
|
||||
### 6. Discover Peers
|
||||
|
||||
Find out which other workspaces your agent can communicate with:
|
||||
|
||||
```bash
|
||||
# Get your workspace's own info
|
||||
curl http://localhost:8080/registry/discover/<your-workspace-id> \
|
||||
-H "Authorization: Bearer <auth_token>" \
|
||||
-H "X-Workspace-ID: <your-workspace-id>"
|
||||
|
||||
# List peers (siblings + parent + children)
|
||||
curl http://localhost:8080/registry/<your-workspace-id>/peers \
|
||||
-H "Authorization: Bearer <auth_token>" \
|
||||
-H "X-Workspace-ID: <your-workspace-id>"
|
||||
```
|
||||
|
||||
The peers endpoint returns workspaces that your agent is allowed to
|
||||
communicate with, based on the hierarchy rules below.
|
||||
|
||||
---
|
||||
|
||||
## Communication Rules
|
||||
|
||||
The platform enforces strict hierarchy-based access control via
|
||||
`CanCommunicate(callerID, targetID)`:
|
||||
|
||||
| Relationship | Allowed |
|
||||
|---|---|
|
||||
| Same workspace (self-call) | Yes |
|
||||
| Siblings (same `parent_id`) | Yes |
|
||||
| Root-level siblings (both `parent_id` is NULL) | Yes |
|
||||
| Parent to child | Yes |
|
||||
| Child to parent | Yes |
|
||||
| Everything else | **Denied** |
|
||||
|
||||
Canvas requests (no `X-Workspace-ID` header) and system callers
|
||||
(`webhook:*`, `system:*`, `test:*` prefixes) bypass this check.
|
||||
|
||||
---
|
||||
|
||||
## Canvas Appearance
|
||||
|
||||
External workspaces appear on the canvas with a purple **REMOTE** badge
|
||||
instead of the usual runtime pill (e.g., "LANGGRAPH", "CLAUDE-CODE").
|
||||
|
||||
They support all standard canvas features:
|
||||
|
||||
- Drag-and-drop positioning (persisted via `PATCH /workspaces/:id`)
|
||||
- Nesting into team nodes (set `parent_id` on create or move via API)
|
||||
- Real-time status updates -- heartbeat data (active tasks, current task,
|
||||
error rate) is broadcast to canvas clients via WebSocket
|
||||
- Chat panel -- "My Chat" tab sends A2A messages to the agent; "Agent Comms"
|
||||
tab shows inter-agent traffic
|
||||
- Config and secrets management via the detail panel
|
||||
|
||||
---
|
||||
|
||||
## Example: Python Implementation
|
||||
|
||||
A minimal external agent using `requests` and `flask`:
|
||||
|
||||
```python
|
||||
"""
|
||||
Minimal Molecule AI external agent.
|
||||
|
||||
pip install flask requests
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import requests
|
||||
from flask import Flask, request, jsonify
|
||||
|
||||
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080")
|
||||
AGENT_URL = os.environ.get("AGENT_URL") # e.g. "https://my-agent.ngrok.io"
|
||||
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "") # for POST /workspaces
|
||||
AGENT_NAME = os.environ.get("AGENT_NAME", "Python External Agent")
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# --- State ---
|
||||
workspace_id = None
|
||||
auth_token = None
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
# --- Step 4: Handle incoming A2A messages ---
|
||||
@app.route("/", methods=["POST"])
|
||||
def handle_a2a():
|
||||
payload = request.get_json(force=True)
|
||||
method = payload.get("method", "")
|
||||
req_id = payload.get("id", "unknown")
|
||||
|
||||
if method == "message/send":
|
||||
text = ""
|
||||
parts = (
|
||||
payload.get("params", {}).get("message", {}).get("parts", [])
|
||||
)
|
||||
for part in parts:
|
||||
if part.get("type") == "text":
|
||||
text += part.get("text", "")
|
||||
|
||||
# --- Your agent logic here ---
|
||||
reply = f"Received: {text}. Processing complete."
|
||||
|
||||
return jsonify({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"message": {
|
||||
"role": "agent",
|
||||
"parts": [{"type": "text", "text": reply}],
|
||||
}
|
||||
},
|
||||
"id": req_id,
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"jsonrpc": "2.0",
|
||||
"error": {"code": -32601, "message": f"Unknown method: {method}"},
|
||||
"id": req_id,
|
||||
})
|
||||
|
||||
|
||||
# --- Step 3: Heartbeat loop ---
|
||||
def heartbeat_loop():
|
||||
while True:
|
||||
try:
|
||||
requests.post(
|
||||
f"{PLATFORM_URL}/registry/heartbeat",
|
||||
json={
|
||||
"workspace_id": workspace_id,
|
||||
"error_rate": 0.0,
|
||||
"active_tasks": 0,
|
||||
"current_task": "",
|
||||
"uptime_seconds": int(time.time() - start_time),
|
||||
},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Heartbeat failed: {e}", file=sys.stderr)
|
||||
time.sleep(30)
|
||||
|
||||
|
||||
def register():
|
||||
global workspace_id, auth_token
|
||||
|
||||
if not AGENT_URL:
|
||||
print("ERROR: Set AGENT_URL to your publicly reachable endpoint", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Step 1: Create external workspace
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if ADMIN_TOKEN:
|
||||
headers["Authorization"] = f"Bearer {ADMIN_TOKEN}"
|
||||
|
||||
resp = requests.post(
|
||||
f"{PLATFORM_URL}/workspaces",
|
||||
json={
|
||||
"name": AGENT_NAME,
|
||||
"runtime": "external",
|
||||
"external": True,
|
||||
"url": AGENT_URL,
|
||||
"tier": 2,
|
||||
},
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
workspace_id = resp.json()["id"]
|
||||
print(f"Created workspace: {workspace_id}")
|
||||
|
||||
# Step 2: Register with the platform
|
||||
resp = requests.post(
|
||||
f"{PLATFORM_URL}/registry/register",
|
||||
json={
|
||||
"id": workspace_id,
|
||||
"url": AGENT_URL,
|
||||
"agent_card": {
|
||||
"name": AGENT_NAME,
|
||||
"description": "A minimal Python external agent",
|
||||
"skills": ["echo"],
|
||||
"runtime": "external",
|
||||
},
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
auth_token = data.get("auth_token")
|
||||
if auth_token:
|
||||
print(f"Auth token received (save this!): {auth_token[:12]}...")
|
||||
else:
|
||||
print("No new token issued (re-registration). Use your saved token.")
|
||||
auth_token = os.environ.get("AUTH_TOKEN")
|
||||
if not auth_token:
|
||||
print("ERROR: Set AUTH_TOKEN for re-registration", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Start heartbeat
|
||||
t = threading.Thread(target=heartbeat_loop, daemon=True)
|
||||
t.start()
|
||||
print("Heartbeat loop started (every 30s)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
register()
|
||||
print(f"Listening on {AGENT_URL}")
|
||||
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)))
|
||||
```
|
||||
|
||||
Run it:
|
||||
|
||||
```bash
|
||||
export PLATFORM_URL=http://localhost:8080
|
||||
export AGENT_URL=https://my-agent.ngrok.io
|
||||
export ADMIN_TOKEN=your-admin-bearer-token
|
||||
python external_agent.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example: Node.js Implementation
|
||||
|
||||
A minimal external agent using the built-in `fetch` and `express`:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Minimal Molecule AI external agent.
|
||||
*
|
||||
* npm install express
|
||||
* Node.js 18+ required (native fetch).
|
||||
*/
|
||||
|
||||
const express = require("express");
|
||||
|
||||
const PLATFORM_URL = process.env.PLATFORM_URL || "http://localhost:8080";
|
||||
const AGENT_URL = process.env.AGENT_URL; // e.g. "https://my-agent.ngrok.io"
|
||||
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || "";
|
||||
const AGENT_NAME = process.env.AGENT_NAME || "Node External Agent";
|
||||
const PORT = parseInt(process.env.PORT || "5000", 10);
|
||||
|
||||
let workspaceId = null;
|
||||
let authToken = null;
|
||||
const startTime = Date.now();
|
||||
|
||||
// --- Step 4: Handle incoming A2A messages ---
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.post("/", (req, res) => {
|
||||
const { method, id: reqId, params } = req.body;
|
||||
|
||||
if (method === "message/send") {
|
||||
const parts = params?.message?.parts || [];
|
||||
const text = parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("");
|
||||
|
||||
// --- Your agent logic here ---
|
||||
const reply = `Received: ${text}. Processing complete.`;
|
||||
|
||||
return res.json({
|
||||
jsonrpc: "2.0",
|
||||
result: {
|
||||
message: {
|
||||
role: "agent",
|
||||
parts: [{ type: "text", text: reply }],
|
||||
},
|
||||
},
|
||||
id: reqId,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32601, message: `Unknown method: ${method}` },
|
||||
id: reqId,
|
||||
});
|
||||
});
|
||||
|
||||
// --- Step 3: Heartbeat loop ---
|
||||
function startHeartbeat() {
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await fetch(`${PLATFORM_URL}/registry/heartbeat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workspace_id: workspaceId,
|
||||
error_rate: 0.0,
|
||||
active_tasks: 0,
|
||||
current_task: "",
|
||||
uptime_seconds: Math.floor((Date.now() - startTime) / 1000),
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Heartbeat failed:", err.message);
|
||||
}
|
||||
}, 30_000);
|
||||
}
|
||||
|
||||
// --- Steps 1 & 2: Create + register ---
|
||||
async function register() {
|
||||
if (!AGENT_URL) {
|
||||
console.error("ERROR: Set AGENT_URL to your publicly reachable endpoint");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Step 1: Create external workspace
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (ADMIN_TOKEN) headers["Authorization"] = `Bearer ${ADMIN_TOKEN}`;
|
||||
|
||||
const createResp = await fetch(`${PLATFORM_URL}/workspaces`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
name: AGENT_NAME,
|
||||
runtime: "external",
|
||||
external: true,
|
||||
url: AGENT_URL,
|
||||
tier: 2,
|
||||
}),
|
||||
});
|
||||
if (!createResp.ok) throw new Error(`Create failed: ${createResp.status}`);
|
||||
const createData = await createResp.json();
|
||||
workspaceId = createData.id;
|
||||
console.log(`Created workspace: ${workspaceId}`);
|
||||
|
||||
// Step 2: Register
|
||||
const regResp = await fetch(`${PLATFORM_URL}/registry/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: workspaceId,
|
||||
url: AGENT_URL,
|
||||
agent_card: {
|
||||
name: AGENT_NAME,
|
||||
description: "A minimal Node.js external agent",
|
||||
skills: ["echo"],
|
||||
runtime: "external",
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!regResp.ok) throw new Error(`Register failed: ${regResp.status}`);
|
||||
const regData = await regResp.json();
|
||||
|
||||
authToken = regData.auth_token;
|
||||
if (authToken) {
|
||||
console.log(`Auth token received (save this!): ${authToken.slice(0, 12)}...`);
|
||||
} else {
|
||||
console.log("No new token issued (re-registration). Using saved token.");
|
||||
authToken = process.env.AUTH_TOKEN;
|
||||
if (!authToken) {
|
||||
console.error("ERROR: Set AUTH_TOKEN for re-registration");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
startHeartbeat();
|
||||
console.log("Heartbeat loop started (every 30s)");
|
||||
}
|
||||
|
||||
register().then(() => {
|
||||
app.listen(PORT, () => console.log(`Listening on port ${PORT}`));
|
||||
});
|
||||
```
|
||||
|
||||
Run it:
|
||||
|
||||
```bash
|
||||
export PLATFORM_URL=http://localhost:8080
|
||||
export AGENT_URL=https://my-agent.ngrok.io
|
||||
export ADMIN_TOKEN=your-admin-bearer-token
|
||||
node external_agent.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle
|
||||
|
||||
External workspaces follow a simplified version of the standard lifecycle:
|
||||
|
||||
```
|
||||
provisioning --> online (on create with URL, or on register)
|
||||
|
|
||||
v
|
||||
degraded (error_rate > 0.5 in heartbeat)
|
||||
|
|
||||
v
|
||||
online (error_rate recovers)
|
||||
|
|
||||
v
|
||||
offline (heartbeat TTL expires after 60s)
|
||||
|
|
||||
v
|
||||
removed (DELETE /workspaces/:id)
|
||||
```
|
||||
|
||||
Key differences from platform-managed workspaces:
|
||||
|
||||
- **No Docker health sweep** -- external workspaces are invisible to the
|
||||
Docker API. Only the Redis heartbeat TTL determines liveness.
|
||||
- **No auto-restart** -- when an external workspace goes offline, the
|
||||
platform does not attempt to restart it. Your agent is responsible for
|
||||
re-registering and resuming heartbeats.
|
||||
- **Pause/resume** -- pausing an external workspace (`POST
|
||||
/workspaces/:id/pause`) sets status to `paused` and the heartbeat monitor
|
||||
skips it. Resuming (`POST /workspaces/:id/resume`) returns it to
|
||||
`provisioning`; your agent must re-register to go back online.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
### Auth Token
|
||||
|
||||
- Tokens are 256-bit cryptographically random values.
|
||||
- The raw token is returned **once** at first registration. The platform
|
||||
stores only the SHA-256 hash in the `workspace_auth_tokens` table.
|
||||
- Lost tokens cannot be recovered. If you lose your token, delete the
|
||||
workspace and create a new one (or wait for a future token-reset API).
|
||||
- Tokens are automatically revoked when a workspace is deleted.
|
||||
|
||||
### Required Headers
|
||||
|
||||
| Endpoint | Required Headers |
|
||||
|----------|-----------------|
|
||||
| `POST /registry/heartbeat` | `Authorization: Bearer <token>` |
|
||||
| `POST /registry/update-card` | `Authorization: Bearer <token>` |
|
||||
| `POST /workspaces/:target/a2a` | `Authorization: Bearer <token>`, `X-Workspace-ID: <your-id>` |
|
||||
| `GET /registry/discover/:id` | `Authorization: Bearer <token>`, `X-Workspace-ID: <your-id>` |
|
||||
| `GET /registry/:id/peers` | `Authorization: Bearer <token>`, `X-Workspace-ID: <your-id>` |
|
||||
|
||||
### Legacy / Bootstrap Behavior
|
||||
|
||||
Workspaces that registered before the token system existed (Phase 30.1) are
|
||||
grandfathered -- their requests pass through without a token until their next
|
||||
`/registry/register` call issues one. After that, the token is enforced on
|
||||
every subsequent call.
|
||||
|
||||
---
|
||||
|
||||
## Updating Your Agent Card
|
||||
|
||||
If your agent's capabilities change, update the card without re-registering:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/registry/update-card \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <auth_token>" \
|
||||
-d '{
|
||||
"workspace_id": "<workspace-id>",
|
||||
"agent_card": {
|
||||
"name": "My External Agent v2",
|
||||
"description": "Now with summarization and translation",
|
||||
"skills": ["research", "summarization", "translation"],
|
||||
"runtime": "external"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Workspace shows "offline" on the canvas
|
||||
|
||||
**Cause:** Heartbeat has not been received in the last 60 seconds.
|
||||
|
||||
**Fix:** Verify your heartbeat loop is running, sending to the correct
|
||||
`workspace_id`, and including `Authorization: Bearer <token>`. Check network
|
||||
connectivity between your agent and the platform. Inspect platform logs for
|
||||
401 errors.
|
||||
|
||||
### 401 Unauthorized on heartbeat
|
||||
|
||||
**Cause:** Missing or invalid auth token.
|
||||
|
||||
**Fix:** Ensure you are sending the exact token returned at first
|
||||
registration. Tokens are case-sensitive. If you lost the token, you must
|
||||
delete the workspace and re-create it.
|
||||
|
||||
### Cannot send A2A messages (403 or communication denied)
|
||||
|
||||
**Cause:** The `CanCommunicate` check failed -- your workspace is not a
|
||||
sibling, parent, or child of the target.
|
||||
|
||||
**Fix:** Check the hierarchy. Use `GET /registry/<your-id>/peers` to see
|
||||
which workspaces you can reach. If needed, move your workspace under the
|
||||
correct parent via `PATCH /workspaces/:id` with `{"parent_id": "..."}`.
|
||||
|
||||
### Agent card not showing on canvas
|
||||
|
||||
**Cause:** Registration was not called, or the `agent_card` JSON was
|
||||
malformed.
|
||||
|
||||
**Fix:** Call `POST /registry/register` again with a valid `agent_card`
|
||||
object. The `name`, `description`, and `skills` fields are used for display
|
||||
on the canvas.
|
||||
|
||||
### Messages not reaching your agent
|
||||
|
||||
**Cause:** The URL you registered is not reachable from the platform.
|
||||
|
||||
**Fix:**
|
||||
1. Confirm the URL is correct: `curl -X POST <your-url> -d '{}'`
|
||||
2. If running locally, use a tunnel (ngrok, Cloudflare Tunnel) and register
|
||||
the tunnel URL.
|
||||
3. Check that your agent's HTTP server is binding to `0.0.0.0`, not just
|
||||
`127.0.0.1`.
|
||||
4. Verify firewall rules allow inbound traffic on your agent's port.
|
||||
|
||||
### Workspace stuck in "provisioning"
|
||||
|
||||
**Cause:** The `external: true` flag was not set on create, so the platform
|
||||
tried to provision a Docker container.
|
||||
|
||||
**Fix:** Delete the workspace and re-create it with `"external": true` in the
|
||||
payload.
|
||||
|
||||
### Re-registration does not return a token
|
||||
|
||||
**Expected behavior.** The token is issued only on first registration. On
|
||||
re-registration, use the token you saved from the first time. If you need to
|
||||
start fresh, delete the workspace and create a new one.
|
||||
|
||||
### Heartbeat succeeds but status stays "degraded"
|
||||
|
||||
**Cause:** Your heartbeat is reporting `error_rate` > 0.5.
|
||||
|
||||
**Fix:** Lower the `error_rate` field in your heartbeat payload. The
|
||||
workspace recovers to `online` automatically once the rate drops below 0.5.
|
||||
245
docs/guides/mcp-server-setup.md
Normal file
245
docs/guides/mcp-server-setup.md
Normal file
@ -0,0 +1,245 @@
|
||||
# MCP Server Setup Guide
|
||||
|
||||
The Molecule AI MCP server lets any MCP-compatible AI agent (Claude Code, Cursor, etc.) manage workspaces, agents, secrets, memory, schedules, channels, and more through the platform API.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install
|
||||
|
||||
The MCP server is published as `@molecule-ai/mcp-server` on npm.
|
||||
|
||||
```bash
|
||||
npx @molecule-ai/mcp-server
|
||||
```
|
||||
|
||||
### 2. Configure in `.mcp.json`
|
||||
|
||||
Add to your project's `.mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"molecule": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@molecule-ai/mcp-server"],
|
||||
"env": {
|
||||
"MOLECULE_URL": "http://localhost:8080"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For production/SaaS deployments, set `MOLECULE_URL` to your tenant URL:
|
||||
```json
|
||||
"MOLECULE_URL": "https://hongming-wang.moleculesai.app"
|
||||
```
|
||||
|
||||
### 3. Verify
|
||||
|
||||
Once configured, your MCP client should show 87 Molecule AI tools. Test with:
|
||||
```
|
||||
list_workspaces
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MOLECULE_URL` | `http://localhost:8080` | Platform API URL |
|
||||
|
||||
## Tool Reference
|
||||
|
||||
### Workspace Management
|
||||
|
||||
| MCP Tool | API Route | Method | Description |
|
||||
|----------|-----------|--------|-------------|
|
||||
| `list_workspaces` | `/workspaces` | GET | List all workspaces |
|
||||
| `get_workspace` | `/workspaces/:id` | GET | Get workspace details |
|
||||
| `create_workspace` | `/workspaces` | POST | Create a new workspace |
|
||||
| `update_workspace` | `/workspaces/:id` | PATCH | Update workspace fields |
|
||||
| `delete_workspace` | `/workspaces/:id` | DELETE | Delete a workspace |
|
||||
| `restart_workspace` | `/workspaces/:id/restart` | POST | Restart workspace container |
|
||||
| `pause_workspace` | `/workspaces/:id/pause` | POST | Pause workspace |
|
||||
| `resume_workspace` | `/workspaces/:id/resume` | POST | Resume paused workspace |
|
||||
| `discover_workspace` | `/registry/discover/:id` | GET | Get workspace URL + agent card |
|
||||
|
||||
### Agent Management
|
||||
|
||||
| MCP Tool | API Route | Method | Description |
|
||||
|----------|-----------|--------|-------------|
|
||||
| `assign_agent` | `/workspaces/:id/agent` | POST | Assign agent to workspace |
|
||||
| `remove_agent` | `/workspaces/:id/agent` | DELETE | Remove agent |
|
||||
| `replace_agent` | `/workspaces/:id/agent` | PATCH | Replace agent config |
|
||||
| `move_agent` | `/workspaces/:id/agent/move` | POST | Move agent to different workspace |
|
||||
|
||||
### Communication
|
||||
|
||||
| MCP Tool | API Route | Method | Description |
|
||||
|----------|-----------|--------|-------------|
|
||||
| `chat_with_agent` | `/workspaces/:id/a2a` | POST | Send A2A message to agent |
|
||||
| `async_delegate` | `/workspaces/:id/delegate` | POST | Fire-and-forget delegation |
|
||||
| `check_delegations` | `/workspaces/:id/delegations` | GET | Check delegation status |
|
||||
| `send_channel_message` | `/workspaces/:id/channels/:channelId/send` | POST | Send to social channel |
|
||||
| `notify_user` | `/workspaces/:id/notify` | POST | Push notification to canvas |
|
||||
| `list_peers` | `/registry/:id/peers` | GET | Find sibling/parent workspaces |
|
||||
| `check_access` | `/registry/check-access` | POST | Check if two workspaces can communicate |
|
||||
|
||||
### Configuration
|
||||
|
||||
| MCP Tool | API Route | Method | Description |
|
||||
|----------|-----------|--------|-------------|
|
||||
| `get_config` | `/workspaces/:id/config` | GET | Get workspace config.yaml |
|
||||
| `update_config` | `/workspaces/:id/config` | PATCH | Update config fields |
|
||||
| `get_model` | `/workspaces/:id/model` | GET | Get configured LLM model |
|
||||
|
||||
### Secrets
|
||||
|
||||
| MCP Tool | API Route | Method | Description |
|
||||
|----------|-----------|--------|-------------|
|
||||
| `list_secrets` | `/workspaces/:id/secrets` | GET | List workspace secret keys |
|
||||
| `set_secret` | `/workspaces/:id/secrets` | POST | Set a workspace secret |
|
||||
| `delete_secret` | `/workspaces/:id/secrets/:key` | DELETE | Delete a secret |
|
||||
| `list_global_secrets` | `/settings/secrets` | GET | List global secrets |
|
||||
| `set_global_secret` | `/settings/secrets` | PUT | Set a global secret |
|
||||
| `delete_global_secret` | `/settings/secrets/:key` | DELETE | Delete global secret |
|
||||
|
||||
### Memory
|
||||
|
||||
| MCP Tool | API Route | Method | Description |
|
||||
|----------|-----------|--------|-------------|
|
||||
| `memory_list` | `/workspaces/:id/memory` | GET | List memory keys |
|
||||
| `memory_get` | `/workspaces/:id/memory/:key` | GET | Get memory value |
|
||||
| `memory_set` | `/workspaces/:id/memory` | POST | Set memory key-value |
|
||||
| `memory_delete_kv` | `/workspaces/:id/memory/:key` | DELETE | Delete memory key |
|
||||
| `search_memory` | `/workspaces/:id/memories` | GET | Full-text search memories |
|
||||
| `commit_memory` | `/workspaces/:id/memories` | POST | Commit HMA memory |
|
||||
| `delete_memory` | `/workspaces/:id/memories/:id` | DELETE | Delete HMA memory |
|
||||
|
||||
### Files
|
||||
|
||||
| MCP Tool | API Route | Method | Description |
|
||||
|----------|-----------|--------|-------------|
|
||||
| `list_files` | `/workspaces/:id/files` | GET | List workspace files |
|
||||
| `read_file` | `/workspaces/:id/files/*path` | GET | Read file content |
|
||||
| `write_file` | `/workspaces/:id/files/*path` | PUT | Write/overwrite file |
|
||||
| `delete_file` | `/workspaces/:id/files/*path` | DELETE | Delete file |
|
||||
| `replace_all_files` | `/workspaces/:id/files` | PUT | Replace all files atomically |
|
||||
|
||||
### Schedules
|
||||
|
||||
| MCP Tool | API Route | Method | Description |
|
||||
|----------|-----------|--------|-------------|
|
||||
| `list_schedules` | `/workspaces/:id/schedules` | GET | List cron schedules |
|
||||
| `create_schedule` | `/workspaces/:id/schedules` | POST | Create cron schedule |
|
||||
| `update_schedule` | `/workspaces/:id/schedules/:id` | PATCH | Update schedule |
|
||||
| `delete_schedule` | `/workspaces/:id/schedules/:id` | DELETE | Delete schedule |
|
||||
| `run_schedule` | `/workspaces/:id/schedules/:id/run` | POST | Trigger schedule now |
|
||||
| `get_schedule_history` | `/workspaces/:id/schedules/:id/history` | GET | Past run history |
|
||||
|
||||
### Channels (Social Integrations)
|
||||
|
||||
| MCP Tool | API Route | Method | Description |
|
||||
|----------|-----------|--------|-------------|
|
||||
| `list_channels` | `/workspaces/:id/channels` | GET | List configured channels |
|
||||
| `add_channel` | `/workspaces/:id/channels` | POST | Add Telegram/Slack/Lark channel |
|
||||
| `update_channel` | `/workspaces/:id/channels/:id` | PATCH | Update channel config |
|
||||
| `remove_channel` | `/workspaces/:id/channels/:id` | DELETE | Remove channel |
|
||||
| `test_channel` | `/workspaces/:id/channels/:id/test` | POST | Test channel connectivity |
|
||||
| `list_channel_adapters` | `/channels/adapters` | GET | Available platforms |
|
||||
| `discover_channel_chats` | `/channels/discover` | POST | Auto-detect chats for bot token |
|
||||
|
||||
### Plugins
|
||||
|
||||
| MCP Tool | API Route | Method | Description |
|
||||
|----------|-----------|--------|-------------|
|
||||
| `list_installed_plugins` | `/workspaces/:id/plugins` | GET | List installed plugins |
|
||||
| `install_plugin` | `/workspaces/:id/plugins` | POST | Install plugin from source |
|
||||
| `uninstall_plugin` | `/workspaces/:id/plugins/:name` | DELETE | Uninstall plugin |
|
||||
| `list_available_plugins` | `/workspaces/:id/plugins/available` | GET | Plugins matching runtime |
|
||||
| `list_plugin_registry` | `/plugins` | GET | Full plugin registry |
|
||||
| `list_plugin_sources` | `/plugins/sources` | GET | Registered source schemes |
|
||||
| `check_plugin_compatibility` | `/workspaces/:id/plugins/compatibility` | GET | Preflight check |
|
||||
|
||||
### Teams / Hierarchy
|
||||
|
||||
| MCP Tool | API Route | Method | Description |
|
||||
|----------|-----------|--------|-------------|
|
||||
| `expand_team` | `/workspaces/:id/expand` | POST | Expand team node |
|
||||
| `collapse_team` | `/workspaces/:id/collapse` | POST | Collapse team node |
|
||||
|
||||
### Templates & Bundles
|
||||
|
||||
| MCP Tool | API Route | Method | Description |
|
||||
|----------|-----------|--------|-------------|
|
||||
| `list_templates` | `/templates` | GET | Available templates |
|
||||
| `import_template` | `/templates/import` | POST | Import template |
|
||||
| `list_org_templates` | `/org/templates` | GET | Org template list |
|
||||
| `import_org` | `/org/import` | POST | Import org template |
|
||||
| `export_bundle` | `/bundles/export/:id` | GET | Export workspace bundle |
|
||||
| `import_bundle` | `/bundles/import` | POST | Import workspace bundle |
|
||||
|
||||
### Tokens
|
||||
|
||||
| MCP Tool | API Route | Method | Description |
|
||||
|----------|-----------|--------|-------------|
|
||||
| `list_tokens` | `/workspaces/:id/tokens` | GET | List workspace tokens |
|
||||
| `create_token` | `/workspaces/:id/tokens` | POST | Create new bearer token |
|
||||
| `revoke_token` | `/workspaces/:id/tokens/:id` | DELETE | Revoke specific token |
|
||||
|
||||
### Monitoring
|
||||
|
||||
| MCP Tool | API Route | Method | Description |
|
||||
|----------|-----------|--------|-------------|
|
||||
| `list_activity` | `/workspaces/:id/activity` | GET | Activity log |
|
||||
| `report_activity` | `/workspaces/:id/activity` | POST | Report agent activity |
|
||||
| `list_events` | `/events` | GET | Platform event stream |
|
||||
| `list_traces` | `/workspaces/:id/traces` | GET | LLM traces (Langfuse) |
|
||||
| `session_search` | `/workspaces/:id/session-search` | GET | Search chat sessions |
|
||||
|
||||
### Approvals
|
||||
|
||||
| MCP Tool | API Route | Method | Description |
|
||||
|----------|-----------|--------|-------------|
|
||||
| `create_approval` | `/workspaces/:id/approvals` | POST | Request approval |
|
||||
| `get_workspace_approvals` | `/workspaces/:id/approvals` | GET | List approvals |
|
||||
| `decide_approval` | `/workspaces/:id/approvals/:id/decide` | POST | Approve/reject |
|
||||
| `list_pending_approvals` | `/approvals/pending` | GET | All pending approvals |
|
||||
|
||||
### Canvas
|
||||
|
||||
| MCP Tool | API Route | Method | Description |
|
||||
|----------|-----------|--------|-------------|
|
||||
| `get_canvas_viewport` | `/canvas/viewport` | GET | Current viewport |
|
||||
| `set_canvas_viewport` | `/canvas/viewport` | PUT | Set viewport position |
|
||||
|
||||
### Remote Agents
|
||||
|
||||
| MCP Tool | API Route | Method | Description |
|
||||
|----------|-----------|--------|-------------|
|
||||
| `list_remote_agents` | `/workspaces?runtime=external` | GET | List remote agents |
|
||||
| `get_remote_agent_state` | `/registry/discover/:id` | GET | Remote agent status |
|
||||
| `check_remote_agent_freshness` | `/registry/heartbeat` | POST | Check if agent is alive |
|
||||
| `get_remote_agent_setup_command` | (local) | — | Get setup instructions |
|
||||
|
||||
## Authentication
|
||||
|
||||
Most routes require a bearer token:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer <token>" http://localhost:8080/workspaces
|
||||
```
|
||||
|
||||
Tokens are issued on workspace registration (`POST /registry/register`) or via the token management API (`POST /workspaces/:id/tokens`).
|
||||
|
||||
The MCP server handles auth automatically when configured with the correct `MOLECULE_URL`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Fix |
|
||||
|-------|-----|
|
||||
| "Connection refused" | Check `MOLECULE_URL` points to running platform |
|
||||
| "401 Unauthorized" | Token expired or revoked — create a new one |
|
||||
| Tools not showing | Run `npx @molecule-ai/mcp-server` standalone to check for errors |
|
||||
| Stale data | MCP server doesn't cache — check platform directly |
|
||||
116
docs/guides/token-management.md
Normal file
116
docs/guides/token-management.md
Normal file
@ -0,0 +1,116 @@
|
||||
# Token Management API
|
||||
|
||||
Workspace bearer tokens authenticate agents and API clients against the Molecule AI platform. Each token is scoped to a single workspace — a token from workspace A cannot access workspace B.
|
||||
|
||||
## Endpoints
|
||||
|
||||
All endpoints are behind `WorkspaceAuth` middleware — you need an existing valid token to manage tokens for a workspace. The first token is issued during workspace registration (`POST /registry/register`).
|
||||
|
||||
### List Tokens
|
||||
|
||||
```
|
||||
GET /workspaces/:id/tokens
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
Returns non-revoked tokens for the workspace. Only metadata is returned — never the plaintext or hash.
|
||||
|
||||
```json
|
||||
{
|
||||
"tokens": [
|
||||
{
|
||||
"id": "uuid-of-token-row",
|
||||
"prefix": "abc12345",
|
||||
"created_at": "2026-04-16T12:00:00Z",
|
||||
"last_used_at": "2026-04-16T15:30:00Z"
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Create Token
|
||||
|
||||
```
|
||||
POST /workspaces/:id/tokens
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
Mints a new token. The plaintext is returned **exactly once** — save it immediately.
|
||||
|
||||
```json
|
||||
{
|
||||
"auth_token": "dGhpcyBpcyBhIHRlc3QgdG9rZW4...",
|
||||
"workspace_id": "ws-uuid",
|
||||
"message": "Save this token now — it cannot be retrieved again."
|
||||
}
|
||||
```
|
||||
|
||||
### Revoke Token
|
||||
|
||||
```
|
||||
DELETE /workspaces/:id/tokens/:tokenId
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
Revokes a specific token by its database ID (from the List response). The token is immediately invalidated.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "revoked"
|
||||
}
|
||||
```
|
||||
|
||||
Returns 404 if the token doesn't exist, belongs to a different workspace, or is already revoked.
|
||||
|
||||
## Token Lifecycle
|
||||
|
||||
```
|
||||
Issue (register or POST /tokens)
|
||||
→ Active (used via Authorization: Bearer)
|
||||
→ Revoked (DELETE /tokens/:id or workspace deleted)
|
||||
```
|
||||
|
||||
- Tokens have no expiration — they remain valid until explicitly revoked or the workspace is deleted
|
||||
- On workspace deletion, all tokens are automatically revoked
|
||||
- Multiple tokens can exist simultaneously per workspace (for rotation)
|
||||
|
||||
## Token Rotation
|
||||
|
||||
To rotate credentials without downtime:
|
||||
|
||||
1. **Create** a new token: `POST /workspaces/:id/tokens`
|
||||
2. **Update** your agent to use the new token
|
||||
3. **Verify** the new token works (check `last_used_at` in List)
|
||||
4. **Revoke** the old token: `DELETE /workspaces/:id/tokens/:oldTokenId`
|
||||
|
||||
## Security Properties
|
||||
|
||||
- **256-bit entropy**: Tokens are 32 random bytes, base64url-encoded (43 characters)
|
||||
- **Hash-only storage**: Only `sha256(token)` is stored in the database — plaintext is never persisted
|
||||
- **Workspace-scoped**: Token from workspace A cannot authenticate as workspace B
|
||||
- **One-time display**: Plaintext returned only at creation — not recoverable from the database
|
||||
- **Prefix for identification**: First 8 characters stored for log correlation without revealing the token
|
||||
|
||||
## Bootstrap: Getting Your First Token
|
||||
|
||||
The first token is issued during workspace registration:
|
||||
|
||||
```bash
|
||||
# 1. Create workspace
|
||||
curl -X POST http://localhost:8080/workspaces \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "My Agent", "tier": 2}'
|
||||
|
||||
# 2. Register (returns auth_token)
|
||||
curl -X POST http://localhost:8080/registry/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"workspace_id": "<id>", "url": "http://...", "agent_card": {...}}'
|
||||
# Response: {"auth_token": "...", ...}
|
||||
```
|
||||
|
||||
For development, the test-token endpoint is also available (disabled in production):
|
||||
```bash
|
||||
curl http://localhost:8080/admin/workspaces/<id>/test-token
|
||||
# Response: {"auth_token": "...", "workspace_id": "..."}
|
||||
```
|
||||
@ -1,7 +1,7 @@
|
||||
# Remote Workspaces — Readiness Audit
|
||||
|
||||
**Status:** scoping doc for Phase 30 (SaaS / Cross-Network Federation)
|
||||
**Last reviewed:** 2026-04-13
|
||||
**Status:** Phase 30.1 shipped (auth tokens + token management API). Phases 30.2–30.7 in progress.
|
||||
**Last reviewed:** 2026-04-16
|
||||
**Scope:** what it takes to let a Python agent on a different machine / different
|
||||
network / behind NAT join the same Molecule AI organization as a first-class workspace.
|
||||
|
||||
@ -93,7 +93,7 @@ drift — grep for the function name.
|
||||
|
||||
| # | Problem | Impact | Solution zone |
|
||||
|---|---------|--------|---------------|
|
||||
| A | **Spoofing.** `X-Workspace-ID` is a namespace header, not auth. Any internet host knowing a workspace ID can impersonate it, call heartbeat, pull secrets, answer A2A as that workspace. | **Blocker.** Cannot expose registry endpoints to the internet without this fix. | Per-workspace auth tokens (30.1). |
|
||||
| A | **Spoofing.** ~~`X-Workspace-ID` is a namespace header, not auth.~~ **SHIPPED (30.1).** Per-workspace bearer tokens now required on heartbeat, update-card, discover, peers, secrets, and all /workspaces/:id/* sub-routes. Token management API: `GET/POST/DELETE /workspaces/:id/tokens`. See [token-management.md](guides/token-management.md). | ~~Blocker~~ **Resolved.** | Per-workspace auth tokens (30.1) ✅ |
|
||||
| B | **NAT / firewall asymmetry.** Agent→platform: fine (outbound). Platform→agent: blocked for most home/office agents. | Anything platform-initiated (config push, restart, plugin install, WS event) fails. | Pull-based APIs for the things that today are pushed (30.2, 30.3, 30.4). |
|
||||
| C | **Secrets delivery.** Today: push at container-create. Remote agent was never provisioned. | Remote agent can't get API keys; any tool that needs them fails. | `GET /workspaces/:id/secrets` (30.2). |
|
||||
| D | **Plugin install.** Today: `docker exec pip install` into the container. No Docker for remote. | Remote agent can't install plugins that require deps. | Plugin tarball download (30.3); agent runs its own install. |
|
||||
@ -144,5 +144,10 @@ state polling (30.4), live A2A proxy auth (30.5), sibling URL cache
|
||||
## 5. Ordered next-step list
|
||||
|
||||
See [PLAN.md Phase 30](../PLAN.md). Eight steps, ~2 weeks to GA.
|
||||
Step 30.1 is the only one that is strictly prerequisite for all the
|
||||
others — ship it first, standalone. Steps 30.2–30.8 can parallelize.
|
||||
Step 30.1 is shipped. Steps 30.2–30.8 can parallelize.
|
||||
|
||||
## 6. Related guides
|
||||
|
||||
- [External Agent Registration Guide](guides/external-agent-registration.md) — step-by-step for any agent to join, with Python + Node.js examples
|
||||
- [Token Management API](guides/token-management.md) — create, list, revoke bearer tokens
|
||||
- [MCP Server Setup](guides/mcp-server-setup.md) — 87 tools for managing workspaces via MCP
|
||||
|
||||
106
platform/internal/handlers/tokens.go
Normal file
106
platform/internal/handlers/tokens.go
Normal file
@ -0,0 +1,106 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TokenHandler exposes user-facing token management for workspaces.
|
||||
// Routes: GET/POST/DELETE /workspaces/:id/tokens (behind WorkspaceAuth).
|
||||
type TokenHandler struct{}
|
||||
|
||||
func NewTokenHandler() *TokenHandler {
|
||||
return &TokenHandler{}
|
||||
}
|
||||
|
||||
type tokenListItem struct {
|
||||
ID string `json:"id"`
|
||||
Prefix string `json:"prefix"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsed *time.Time `json:"last_used_at"`
|
||||
}
|
||||
|
||||
// List returns non-revoked tokens for the workspace (prefix + metadata only,
|
||||
// never the plaintext or hash).
|
||||
func (h *TokenHandler) List(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
|
||||
rows, err := db.DB.QueryContext(c.Request.Context(), `
|
||||
SELECT id, prefix, created_at, last_used_at
|
||||
FROM workspace_auth_tokens
|
||||
WHERE workspace_id = $1 AND revoked_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
`, workspaceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tokens"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tokens := []tokenListItem{}
|
||||
for rows.Next() {
|
||||
var t tokenListItem
|
||||
if err := rows.Scan(&t.ID, &t.Prefix, &t.CreatedAt, &t.LastUsed); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan token"})
|
||||
return
|
||||
}
|
||||
tokens = append(tokens, t)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tokens": tokens,
|
||||
"count": len(tokens),
|
||||
})
|
||||
}
|
||||
|
||||
// Create mints a new token for the workspace. The plaintext is returned
|
||||
// exactly once in the response — it cannot be recovered afterwards.
|
||||
func (h *TokenHandler) Create(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
|
||||
token, err := wsauth.IssueToken(c.Request.Context(), db.DB, workspaceID)
|
||||
if err != nil {
|
||||
log.Printf("tokens: issue failed for %s: %v", workspaceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create token"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("tokens: issued new token for workspace %s", workspaceID)
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"auth_token": token,
|
||||
"workspace_id": workspaceID,
|
||||
"message": "Save this token now — it cannot be retrieved again.",
|
||||
})
|
||||
}
|
||||
|
||||
// Revoke invalidates a specific token by ID. The token ID is the database
|
||||
// row ID visible from List, not the plaintext token itself.
|
||||
func (h *TokenHandler) Revoke(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
tokenID := c.Param("tokenId")
|
||||
|
||||
result, err := db.DB.ExecContext(c.Request.Context(), `
|
||||
UPDATE workspace_auth_tokens
|
||||
SET revoked_at = now()
|
||||
WHERE id = $1 AND workspace_id = $2 AND revoked_at IS NULL
|
||||
`, tokenID, workspaceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to revoke token"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "token not found or already revoked"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("tokens: revoked token %s for workspace %s", tokenID, workspaceID)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "revoked"})
|
||||
}
|
||||
200
platform/internal/handlers/tokens_test.go
Normal file
200
platform/internal/handlers/tokens_test.go
Normal file
@ -0,0 +1,200 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func init() { gin.SetMode(gin.TestMode) }
|
||||
|
||||
// setupTokenTestDB creates an in-memory SQLite-like test or returns early
|
||||
// if the real Postgres test DB is available. For unit tests we use the
|
||||
// package-level db.DB which handlers rely on.
|
||||
func setupTokenTestDB(t *testing.T) func() {
|
||||
t.Helper()
|
||||
if db.DB == nil {
|
||||
t.Skip("db.DB not initialised — run with a test database")
|
||||
}
|
||||
// Quick probe — if the DB is closed or unreachable, skip.
|
||||
if err := db.DB.Ping(); err != nil {
|
||||
t.Skipf("db.DB not reachable: %v", err)
|
||||
}
|
||||
return func() {}
|
||||
}
|
||||
|
||||
func TestTokenHandler_CreateAndList(t *testing.T) {
|
||||
cleanup := setupTokenTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create a test workspace first
|
||||
wsID := createTestWorkspace(t)
|
||||
defer deleteTestWorkspace(t, wsID)
|
||||
|
||||
h := NewTokenHandler()
|
||||
|
||||
// Create a token
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: wsID}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+wsID+"/tokens", nil)
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("Create: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var createResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &createResp)
|
||||
if createResp["auth_token"] == nil || createResp["auth_token"] == "" {
|
||||
t.Fatal("Create: auth_token missing from response")
|
||||
}
|
||||
if createResp["workspace_id"] != wsID {
|
||||
t.Errorf("Create: workspace_id mismatch: got %v", createResp["workspace_id"])
|
||||
}
|
||||
|
||||
// List tokens
|
||||
w2 := httptest.NewRecorder()
|
||||
c2, _ := gin.CreateTestContext(w2)
|
||||
c2.Params = gin.Params{{Key: "id", Value: wsID}}
|
||||
c2.Request = httptest.NewRequest("GET", "/workspaces/"+wsID+"/tokens", nil)
|
||||
h.List(c2)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("List: expected 200, got %d: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
var listResp struct {
|
||||
Tokens []map[string]interface{} `json:"tokens"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
json.Unmarshal(w2.Body.Bytes(), &listResp)
|
||||
if listResp.Count < 1 {
|
||||
t.Errorf("List: expected at least 1 token, got %d", listResp.Count)
|
||||
}
|
||||
|
||||
// Verify token has prefix but NOT the full plaintext
|
||||
tok := listResp.Tokens[0]
|
||||
if tok["prefix"] == nil || tok["prefix"] == "" {
|
||||
t.Error("List: prefix missing")
|
||||
}
|
||||
if tok["id"] == nil {
|
||||
t.Error("List: id missing")
|
||||
}
|
||||
if _, hasAuth := tok["auth_token"]; hasAuth {
|
||||
t.Error("List: auth_token should NOT be in list response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenHandler_Revoke(t *testing.T) {
|
||||
cleanup := setupTokenTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
wsID := createTestWorkspace(t)
|
||||
defer deleteTestWorkspace(t, wsID)
|
||||
|
||||
// Issue a token directly
|
||||
token, err := wsauth.IssueToken(context.Background(), db.DB, wsID)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueToken: %v", err)
|
||||
}
|
||||
_ = token // we don't need the plaintext, just the DB row
|
||||
|
||||
// Find the token ID
|
||||
var tokenID string
|
||||
err = db.DB.QueryRow(`
|
||||
SELECT id FROM workspace_auth_tokens
|
||||
WHERE workspace_id = $1 AND revoked_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`, wsID).Scan(&tokenID)
|
||||
if err != nil {
|
||||
t.Fatalf("find token: %v", err)
|
||||
}
|
||||
|
||||
h := NewTokenHandler()
|
||||
|
||||
// Revoke it
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: wsID}, {Key: "tokenId", Value: tokenID}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/"+wsID+"/tokens/"+tokenID, nil)
|
||||
h.Revoke(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Revoke: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify it's actually revoked
|
||||
var revokedAt sql.NullTime
|
||||
db.DB.QueryRow(`SELECT revoked_at FROM workspace_auth_tokens WHERE id = $1`, tokenID).Scan(&revokedAt)
|
||||
if !revokedAt.Valid {
|
||||
t.Error("Revoke: revoked_at should be set")
|
||||
}
|
||||
|
||||
// Revoking again should 404
|
||||
w2 := httptest.NewRecorder()
|
||||
c2, _ := gin.CreateTestContext(w2)
|
||||
c2.Params = gin.Params{{Key: "id", Value: wsID}, {Key: "tokenId", Value: tokenID}}
|
||||
c2.Request = httptest.NewRequest("DELETE", "/workspaces/"+wsID+"/tokens/"+tokenID, nil)
|
||||
h.Revoke(c2)
|
||||
|
||||
if w2.Code != http.StatusNotFound {
|
||||
t.Errorf("Revoke again: expected 404, got %d", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenHandler_RevokeWrongWorkspace(t *testing.T) {
|
||||
cleanup := setupTokenTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
wsID := createTestWorkspace(t)
|
||||
defer deleteTestWorkspace(t, wsID)
|
||||
|
||||
wsauth.IssueToken(context.Background(), db.DB, wsID)
|
||||
|
||||
var tokenID string
|
||||
db.DB.QueryRow(`
|
||||
SELECT id FROM workspace_auth_tokens
|
||||
WHERE workspace_id = $1 AND revoked_at IS NULL LIMIT 1
|
||||
`, wsID).Scan(&tokenID)
|
||||
|
||||
h := NewTokenHandler()
|
||||
|
||||
// Try to revoke with a different workspace ID — should 404
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "wrong-workspace-id"}, {Key: "tokenId", Value: tokenID}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/wrong/tokens/"+tokenID, nil)
|
||||
h.Revoke(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("Revoke wrong workspace: expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// createTestWorkspace inserts a minimal workspace row for testing.
|
||||
func createTestWorkspace(t *testing.T) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
err := db.DB.QueryRow(`
|
||||
INSERT INTO workspaces (name, status, tier) VALUES ('test-token-ws', 'online', 2)
|
||||
RETURNING id
|
||||
`).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("create test workspace: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func deleteTestWorkspace(t *testing.T, id string) {
|
||||
t.Helper()
|
||||
db.DB.Exec(`DELETE FROM workspace_auth_tokens WHERE workspace_id = $1`, id)
|
||||
db.DB.Exec(`DELETE FROM workspaces WHERE id = $1`, id)
|
||||
}
|
||||
@ -76,15 +76,28 @@ func AdminAuth(database *sql.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
if hasLive {
|
||||
// Bearer token path — agents, CLI, and API clients.
|
||||
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
|
||||
if tok == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "admin auth required"})
|
||||
if tok != "" {
|
||||
if err := wsauth.ValidateAnyToken(ctx, database, tok); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid admin auth token"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if err := wsauth.ValidateAnyToken(ctx, database, tok); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid admin auth token"})
|
||||
// Canvas origin path — cross-origin canvas (CORS_ORIGINS match).
|
||||
if canvasOriginAllowed(c.GetHeader("Origin")) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
// Same-origin canvas path — tenant image where canvas + API share a host.
|
||||
if isSameOriginCanvas(c) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "admin auth required"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
@ -135,16 +148,18 @@ func CanvasOrBearer(database *sql.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Path 2: canvas origin match. Read CORS_ORIGINS at request time so
|
||||
// tests can override via t.Setenv. canvasOriginAllowed returns true
|
||||
// iff Origin is non-empty AND exactly matches one of the configured
|
||||
// origins. Empty Origin (same-origin / server-to-server) does NOT
|
||||
// pass this check — those callers must use the bearer path.
|
||||
// Path 2: canvas origin match (cross-origin canvas).
|
||||
if canvasOriginAllowed(c.GetHeader("Origin")) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Path 3: same-origin canvas (tenant image).
|
||||
if isSameOriginCanvas(c) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "admin auth required"})
|
||||
}
|
||||
}
|
||||
@ -173,3 +188,31 @@ func canvasOriginAllowed(origin string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isSameOriginCanvas returns true when the request appears to come from the
|
||||
// canvas UI served by the same Go process (tenant image). In this topology,
|
||||
// the browser sends same-origin requests with an empty Origin header but a
|
||||
// Referer matching the request Host. We accept these requests because the
|
||||
// canvas is the trusted frontend — same as if Origin matched CORS_ORIGINS.
|
||||
//
|
||||
// This only fires when CANVAS_PROXY_URL is set (i.e. the combined tenant
|
||||
// image is active), so self-hosted / dev setups with separate canvas and
|
||||
// platform origins are unaffected.
|
||||
func isSameOriginCanvas(c *gin.Context) bool {
|
||||
if os.Getenv("CANVAS_PROXY_URL") == "" {
|
||||
return false
|
||||
}
|
||||
referer := c.GetHeader("Referer")
|
||||
if referer == "" {
|
||||
return false
|
||||
}
|
||||
host := c.Request.Host
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
// Referer starts with https://<host>/ or http://<host>/
|
||||
return strings.HasPrefix(referer, "https://"+host+"/") ||
|
||||
strings.HasPrefix(referer, "http://"+host+"/") ||
|
||||
strings.HasPrefix(referer, "https://"+host) ||
|
||||
strings.HasPrefix(referer, "http://"+host)
|
||||
}
|
||||
|
||||
@ -256,6 +256,12 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
|
||||
// (mirrors the /workspaces/:id/a2a pattern). Issue #249.
|
||||
r.GET("/workspaces/:id/schedules/health", schedh.Health)
|
||||
|
||||
// Token management (user-facing create/list/revoke)
|
||||
tokh := handlers.NewTokenHandler()
|
||||
wsAuth.GET("/tokens", tokh.List)
|
||||
wsAuth.POST("/tokens", tokh.Create)
|
||||
wsAuth.DELETE("/tokens/:tokenId", tokh.Revoke)
|
||||
|
||||
// Memory
|
||||
memh := handlers.NewMemoryHandler()
|
||||
wsAuth.GET("/memory", memh.List)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user