Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a053ca6f72 | |||
| dfc9d91ccd | |||
| 9fb7060e9c | |||
| 90e115ba55 | |||
| f233f71f5a | |||
| 82a6cf42cd |
+12
-12
@@ -57,24 +57,24 @@ See `CLAUDE.md` for a full list of environment variables and their purposes.
|
||||
|
||||
This repo is scoped to **code** (canvas, workspace, workspace-server, related
|
||||
infra). Public content (blog posts, marketing copy, OG images, SEO briefs,
|
||||
DevRel demos) lives in [`Molecule-AI/docs`](https://git.moleculesai.app/molecule-ai/docs).
|
||||
DevRel demos) lives in [`molecule-ai/docs`](https://git.moleculesai.app/molecule-ai/docs).
|
||||
The `Block forbidden paths` CI gate fails any PR that writes to `marketing/`
|
||||
or other removed paths — open against `Molecule-AI/docs` instead.
|
||||
or other removed paths — open against `molecule-ai/docs` instead.
|
||||
|
||||
| Content type | Target |
|
||||
|---|---|
|
||||
| Blog posts | `Molecule-AI/docs` → `content/blog/<YYYY-MM-DD-slug>/` |
|
||||
| Doc pages | `Molecule-AI/docs` → `content/docs/` |
|
||||
| Marketing copy / PMM positioning | `Molecule-AI/docs` → `marketing/` |
|
||||
| OG images, visual assets | `Molecule-AI/docs` → `app/` or `marketing/` |
|
||||
| SEO briefs | `Molecule-AI/docs` → `marketing/` |
|
||||
| DevRel demos (runnable code) | Standalone repo under `Molecule-AI/`, OR embedded in `Molecule-AI/docs` |
|
||||
| Blog posts | `molecule-ai/docs` → `content/blog/<YYYY-MM-DD-slug>/` |
|
||||
| Doc pages | `molecule-ai/docs` → `content/docs/` |
|
||||
| Marketing copy / PMM positioning | `molecule-ai/docs` → `marketing/` |
|
||||
| OG images, visual assets | `molecule-ai/docs` → `app/` or `marketing/` |
|
||||
| SEO briefs | `molecule-ai/docs` → `marketing/` |
|
||||
| DevRel demos (runnable code) | Standalone repo under `molecule-ai/`, OR embedded in `molecule-ai/docs` |
|
||||
| Launch checklists, internal tracking | GitHub Issues — **not** committed files |
|
||||
| Engineering docs (`docs/adr/`, `docs/architecture/`, `docs/incidents/`) | This repo (internal, not published) |
|
||||
| Live product pages (e.g. `canvas/src/app/pricing/page.tsx`) | This repo (these are app code, not marketing copy) |
|
||||
|
||||
If a PR fails the `Block forbidden paths` check, the contents belong in
|
||||
`Molecule-AI/docs`. No CI drag, no Canvas E2E, content lands in minutes.
|
||||
`molecule-ai/docs`. No CI drag, no Canvas E2E, content lands in minutes.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -190,9 +190,9 @@ Runs the full regression suite against a fixture HTTP server. No network access
|
||||
Code in this repo lands in molecule-core. Some related runtime artifacts
|
||||
live in their own repos:
|
||||
|
||||
- [`Molecule-AI/molecule-ai-workspace-runtime`](https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-runtime) — Python adapter SDK (`molecule_runtime`) that runs inside containerized Molecule workspaces. Bridges Claude Code SDK / hermes / langgraph / etc. → A2A queue.
|
||||
- [`Molecule-AI/molecule-sdk-python`](https://git.moleculesai.app/molecule-ai/molecule-sdk-python) — `A2AServer` + `RemoteAgentClient` for external agents that register over the public `/registry/register` flow.
|
||||
- [`Molecule-AI/molecule-mcp-claude-channel`](https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel) — Claude Code channel plugin. Bridges A2A traffic into a running Claude Code session via MCP `notifications/claude/channel`. Polling-based (no tunnel required); install with `claude --channels plugin:molecule@Molecule-AI/molecule-mcp-claude-channel`.
|
||||
- [`molecule-ai/molecule-ai-workspace-runtime`](https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-runtime) — Python adapter SDK (`molecule_runtime`) that runs inside containerized Molecule workspaces. Bridges Claude Code SDK / hermes / langgraph / etc. → A2A queue.
|
||||
- [`molecule-ai/molecule-sdk-python`](https://git.moleculesai.app/molecule-ai/molecule-sdk-python) — `A2AServer` + `RemoteAgentClient` for external agents that register over the public `/registry/register` flow.
|
||||
- [`molecule-ai/molecule-mcp-claude-channel`](https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel) — Claude Code channel plugin. Bridges A2A traffic into a running Claude Code session via MCP `notifications/claude/channel`. Polling-based (no tunnel required); install inside Claude Code via `/plugin marketplace add https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel.git` → `/plugin install molecule@molecule-channel`, then launch with `claude --dangerously-load-development-channels --channels plugin:molecule@molecule-channel`.
|
||||
|
||||
When extending the **A2A surface** in molecule-core (`workspace-server/internal/handlers/a2a_proxy.go` etc.), consider whether the change has a downstream impact on the runtime SDK or the channel plugin — they're versioned independently but share the wire shape.
|
||||
|
||||
|
||||
@@ -238,7 +238,7 @@ The result is not just “an agent that learns.” It is **an organization that
|
||||
- subscribe to one or more workspaces; peer messages surface as conversation turns; replies route back through Molecule's A2A
|
||||
- no tunnel, no public endpoint — the plugin self-registers each watched workspace as `delivery_mode=poll` and long-polls `/activity?since_id=…`
|
||||
- multi-tenant friendly: one plugin install can watch workspaces across multiple Molecule tenants (`MOLECULE_PLATFORM_URLS` per-workspace)
|
||||
- install via the standard marketplace flow: `/plugin marketplace add Molecule-AI/molecule-mcp-claude-channel` → `/plugin install molecule-channel@molecule-mcp-claude-channel`
|
||||
- install via the standard marketplace flow: `/plugin marketplace add https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel.git` → `/plugin install molecule@molecule-channel`, then launch with `claude --dangerously-load-development-channels --channels plugin:molecule@molecule-channel`
|
||||
|
||||
## Built For Teams That Need More Than A Demo
|
||||
|
||||
|
||||
+1
-1
@@ -237,7 +237,7 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
|
||||
- 订阅一个或多个 workspace;peer 的消息会以 user-turn 出现,回复会经 Molecule A2A 路由出去
|
||||
- 无需公网隧道、无需公开端点 —— 插件启动时自动把每个 watched workspace 注册成 `delivery_mode=poll`,长轮询 `/activity?since_id=…`
|
||||
- 多租户友好:单次安装即可同时 watch 跨多个 Molecule 租户的 workspace(`MOLECULE_PLATFORM_URLS` 按 workspace 配置)
|
||||
- 通过标准 marketplace 流程安装:`/plugin marketplace add Molecule-AI/molecule-mcp-claude-channel` → `/plugin install molecule-channel@molecule-mcp-claude-channel`
|
||||
- 通过标准 marketplace 流程安装:`/plugin marketplace add https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel.git` → `/plugin install molecule@molecule-channel`,然后用 `claude --dangerously-load-development-channels --channels plugin:molecule@molecule-channel` 启动
|
||||
|
||||
## 适合什么团队
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// Marketing-launch SEO (mc#1486). These tests pin the public crawler
|
||||
// contract: anything that flips public marketing routes to disallow,
|
||||
// drops the sitemap from robots.txt, or removes the OG image
|
||||
// reference from root metadata should fail loudly here.
|
||||
|
||||
// next/font and the rest of the layout's runtime tree are not
|
||||
// vitest-compatible (next/font expects the Next.js compiler swc
|
||||
// transform). We import layout.tsx only for its exported `metadata`
|
||||
// constant — mock the font module to a constructor-returning stub.
|
||||
vi.mock("next/font/google", () => ({
|
||||
Inter: () => ({ variable: "--font-inter" }),
|
||||
JetBrains_Mono: () => ({ variable: "--font-jetbrains" }),
|
||||
}));
|
||||
|
||||
import robots from "../robots";
|
||||
import sitemap from "../sitemap";
|
||||
import { metadata } from "../layout";
|
||||
|
||||
describe("robots.ts", () => {
|
||||
it("allows public marketing routes and blocks authed/app routes", () => {
|
||||
const r = robots();
|
||||
expect(r.rules).toBeDefined();
|
||||
const rule = Array.isArray(r.rules) ? r.rules[0] : r.rules!;
|
||||
expect(rule.userAgent).toBe("*");
|
||||
const allow = Array.isArray(rule.allow) ? rule.allow : [rule.allow];
|
||||
expect(allow).toEqual(expect.arrayContaining(["/", "/pricing", "/blog"]));
|
||||
const disallow = Array.isArray(rule.disallow)
|
||||
? rule.disallow
|
||||
: [rule.disallow];
|
||||
expect(disallow).toEqual(
|
||||
expect.arrayContaining(["/api/", "/orgs", "/cp/"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("declares the sitemap URL", () => {
|
||||
const r = robots();
|
||||
expect(r.sitemap).toMatch(/\/sitemap\.xml$/);
|
||||
});
|
||||
|
||||
it("declares a canonical host", () => {
|
||||
const r = robots();
|
||||
expect(r.host).toMatch(/^https:\/\//);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sitemap.ts", () => {
|
||||
it("includes apex, pricing, and the live blog post", () => {
|
||||
const entries = sitemap();
|
||||
const urls = entries.map((e) => e.url);
|
||||
expect(urls.some((u) => u.endsWith("/"))).toBe(true);
|
||||
expect(urls.some((u) => u.endsWith("/pricing"))).toBe(true);
|
||||
expect(
|
||||
urls.some((u) => u.includes("/blog/2026-04-20-chrome-devtools-mcp")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT include authed/app routes", () => {
|
||||
const entries = sitemap();
|
||||
const urls = entries.map((e) => e.url);
|
||||
expect(urls.some((u) => u.includes("/orgs"))).toBe(false);
|
||||
expect(urls.some((u) => u.includes("/api/"))).toBe(false);
|
||||
});
|
||||
|
||||
it("sets a non-zero priority and a valid changeFrequency on every entry", () => {
|
||||
const valid = new Set([
|
||||
"always",
|
||||
"hourly",
|
||||
"daily",
|
||||
"weekly",
|
||||
"monthly",
|
||||
"yearly",
|
||||
"never",
|
||||
]);
|
||||
for (const e of sitemap()) {
|
||||
expect(e.priority).toBeGreaterThan(0);
|
||||
expect(valid.has(String(e.changeFrequency))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("root layout metadata", () => {
|
||||
it("sets a templated title + non-empty description", () => {
|
||||
const t = metadata.title as { default: string; template: string };
|
||||
expect(t.default).toMatch(/Molecule AI/);
|
||||
expect(t.template).toMatch(/%s/);
|
||||
expect((metadata.description ?? "").length).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it("declares OG + Twitter text fields (image comes from opengraph-image.tsx)", () => {
|
||||
const og = metadata.openGraph;
|
||||
expect(og).toBeDefined();
|
||||
expect((og as { title: string }).title).toMatch(/Molecule AI/);
|
||||
expect((og as { description: string }).description.length).toBeGreaterThan(
|
||||
50,
|
||||
);
|
||||
const tw = metadata.twitter;
|
||||
expect(tw).toBeDefined();
|
||||
// Next.js typings narrow twitter.card to a union — assert via cast.
|
||||
expect((tw as { card: string }).card).toBe("summary_large_image");
|
||||
});
|
||||
|
||||
it("sets a canonical alternate", () => {
|
||||
expect(metadata.alternates?.canonical).toBe("/");
|
||||
});
|
||||
|
||||
it("enables indexing at the metadata level (robots.ts owns per-route)", () => {
|
||||
const r = metadata.robots as { index: boolean; follow: boolean };
|
||||
expect(r.index).toBe(true);
|
||||
expect(r.follow).toBe(true);
|
||||
});
|
||||
});
|
||||
+140
-2
@@ -27,9 +27,78 @@ import {
|
||||
themeBootScript,
|
||||
} from "@/lib/theme-cookie";
|
||||
|
||||
// Marketing-launch SEO (mc#1486). Canonical apex is app.moleculesai.app —
|
||||
// tenant subdomains (<slug>.moleculesai.app) reuse the same Next.js build
|
||||
// but are gated behind auth (AuthGate redirects anonymous → /cp/auth/login)
|
||||
// and are de-indexed in robots.ts. The metadata here applies to the
|
||||
// public marketing surface served from the apex host.
|
||||
//
|
||||
// Override per-route by exporting a page-level `metadata`/`generateMetadata`
|
||||
// — Next.js merges page metadata over layout metadata using
|
||||
// `title.template` for "<page> | Molecule AI" composition.
|
||||
const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SITE_URL ?? "https://app.moleculesai.app";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Molecule AI",
|
||||
description: "AI Org Chart Canvas",
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: "Molecule AI — the AI org chart canvas",
|
||||
template: "%s | Molecule AI",
|
||||
},
|
||||
description:
|
||||
"Molecule AI is an org-chart canvas for AI agent teams. Wire Claude Code, Codex, Hermes, and OpenClaw agents into a governed multi-agent workspace with credit metering, audit, and one-click runtime provisioning.",
|
||||
applicationName: "Molecule AI",
|
||||
keywords: [
|
||||
"AI agents",
|
||||
"multi-agent",
|
||||
"agent orchestration",
|
||||
"AI org chart",
|
||||
"Claude Code",
|
||||
"Codex",
|
||||
"MCP",
|
||||
"agent governance",
|
||||
"A2A",
|
||||
"agent runtime",
|
||||
],
|
||||
authors: [{ name: "Molecule AI" }],
|
||||
creator: "Molecule AI",
|
||||
publisher: "Molecule AI",
|
||||
alternates: { canonical: "/" },
|
||||
// OG + Twitter images come from the file-convention sibling
|
||||
// `opengraph-image.tsx` — Next.js auto-attaches them to og:image
|
||||
// and twitter:image when present at the segment root. We keep the
|
||||
// text fields here so they win over per-page metadata when a page
|
||||
// doesn't override them. `images: []` as the structural fallback
|
||||
// for hosts that won't follow the file convention; the real URL
|
||||
// is injected by Next.js at build time from opengraph-image.tsx.
|
||||
openGraph: {
|
||||
type: "website",
|
||||
siteName: "Molecule AI",
|
||||
url: SITE_URL,
|
||||
title: "Molecule AI — the AI org chart canvas",
|
||||
description:
|
||||
"Wire Claude Code, Codex, Hermes, and OpenClaw agents into a governed multi-agent workspace. Credit metering, audit, and one-click runtime provisioning.",
|
||||
locale: "en_US",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Molecule AI — the AI org chart canvas",
|
||||
description:
|
||||
"Wire Claude Code, Codex, Hermes, and OpenClaw agents into a governed multi-agent workspace.",
|
||||
},
|
||||
icons: {
|
||||
icon: "/molecule-icon.png",
|
||||
apple: "/molecule-icon.png",
|
||||
},
|
||||
// robots.ts owns the per-route allow/disallow contract; this is the
|
||||
// header-level fallback for routes the crawler reaches before
|
||||
// robots.txt resolves. Default = index public marketing routes;
|
||||
// app/auth/api/orgs are noindex'd by robots.ts.
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: { index: true, follow: true, "max-image-preview": "large" },
|
||||
},
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
@@ -94,6 +163,75 @@ export default async function RootLayout({
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{ __html: themeBootScript }}
|
||||
/>
|
||||
{/*
|
||||
* JSON-LD structured data (mc#1486). Two graph nodes:
|
||||
*
|
||||
* - Organization: surfaces the brand to Google Knowledge
|
||||
* Graph + Bing entity index. URL+logo+sameAs are the
|
||||
* minimum recommended set for new brands without a
|
||||
* Wikipedia page.
|
||||
*
|
||||
* - WebSite: enables the sitelinks search box and tells
|
||||
* crawlers the canonical site URL when the same content
|
||||
* is reachable via multiple subdomains (apex + tenant).
|
||||
*
|
||||
* Type-application/ld+json runs synchronously without
|
||||
* executing JS, so 'strict-dynamic' isn't required — we still
|
||||
* carry the nonce because production CSP's default-src 'self'
|
||||
* applies to any <script> element. The "type" attribute is
|
||||
* what keeps the browser from running the body as JS, but
|
||||
* CSP nonces are gated on the element not the type, so we
|
||||
* include the nonce too.
|
||||
*/}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "Organization",
|
||||
"@id": `${SITE_URL}#organization`,
|
||||
name: "Molecule AI",
|
||||
url: SITE_URL,
|
||||
logo: `${SITE_URL}/molecule-icon.png`,
|
||||
sameAs: [
|
||||
"https://github.com/molecule-ai",
|
||||
"https://x.com/moleculeai",
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": `${SITE_URL}#website`,
|
||||
url: SITE_URL,
|
||||
name: "Molecule AI",
|
||||
publisher: { "@id": `${SITE_URL}#organization` },
|
||||
inLanguage: "en-US",
|
||||
},
|
||||
{
|
||||
"@type": "SoftwareApplication",
|
||||
"@id": `${SITE_URL}#software`,
|
||||
name: "Molecule AI",
|
||||
applicationCategory: "DeveloperApplication",
|
||||
operatingSystem: "Web",
|
||||
description:
|
||||
"Org-chart canvas for AI agent teams with credit metering, audit, and one-click runtime provisioning.",
|
||||
url: SITE_URL,
|
||||
offers: {
|
||||
"@type": "AggregateOffer",
|
||||
priceCurrency: "USD",
|
||||
lowPrice: "0",
|
||||
highPrice: "99",
|
||||
offerCount: "3",
|
||||
url: `${SITE_URL}/pricing`,
|
||||
},
|
||||
publisher: { "@id": `${SITE_URL}#organization` },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className={`bg-surface text-ink ${interFont.variable} ${monoFont.variable}`}>
|
||||
<ThemeProvider initialTheme={theme}>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
|
||||
// Marketing-launch SEO (mc#1486). Next.js App-Router file-system OG
|
||||
// convention: served as `/opengraph-image` and auto-attached as
|
||||
// `og:image` + `twitter:image`. Dynamic (not a static PNG in /public)
|
||||
// so we can iterate the brand mark + tagline pre-launch without
|
||||
// churning a binary blob in git history.
|
||||
export const runtime = "edge";
|
||||
|
||||
export const alt = "Molecule AI — the AI org chart canvas";
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = "image/png";
|
||||
|
||||
export default function OG() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
padding: "80px",
|
||||
background:
|
||||
"linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 60%, #16213e 100%)",
|
||||
color: "#ffffff",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 28,
|
||||
color: "#a3a3c2",
|
||||
letterSpacing: "0.18em",
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
Molecule AI
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 76,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.05,
|
||||
letterSpacing: "-0.02em",
|
||||
maxWidth: 980,
|
||||
}}
|
||||
>
|
||||
The AI org chart canvas
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: "#c8c8d8",
|
||||
marginTop: 32,
|
||||
lineHeight: 1.3,
|
||||
maxWidth: 980,
|
||||
}}
|
||||
>
|
||||
Wire Claude Code, Codex, Hermes, and OpenClaw agents into a governed
|
||||
multi-agent workspace.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 80,
|
||||
bottom: 80,
|
||||
fontSize: 22,
|
||||
color: "#7a7a96",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
moleculesai.app
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ ...size },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
// Marketing-launch SEO (mc#1486). Next.js App-Router robots convention:
|
||||
// this file is served as `/robots.txt` at build time and is the single
|
||||
// source of truth for crawler allow/disallow.
|
||||
//
|
||||
// Contract:
|
||||
// - Public marketing routes (/, /pricing, /blog/*) are crawlable.
|
||||
// - Authed/app routes (/orgs, /api/*) are noindex'd. They render
|
||||
// useful content only after a session round-trip, so a crawler hit
|
||||
// just wastes our crawl budget and exposes endpoint shapes.
|
||||
// - Tenant subdomains (<slug>.moleculesai.app) share this build but
|
||||
// are blocked at the host level by the canvas middleware sending
|
||||
// an `X-Robots-Tag: noindex` header — robots.txt is per-host and
|
||||
// this file's `host` field claims the apex as canonical.
|
||||
//
|
||||
// Note: `sitemap` is published via the sibling `sitemap.ts` route; we
|
||||
// reference it explicitly here so crawlers don't have to guess.
|
||||
const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SITE_URL ?? "https://app.moleculesai.app";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: ["/", "/pricing", "/blog"],
|
||||
// Authed app surface + API + transient checkout returns. The
|
||||
// /orgs route boots the org-selector behind AuthGate; even
|
||||
// though SSR returns markup, that markup is a login wall when
|
||||
// hit by an unauthenticated crawler, so indexing it dilutes
|
||||
// brand searches with a "Please sign in" snippet.
|
||||
disallow: [
|
||||
"/orgs",
|
||||
"/orgs/",
|
||||
"/api/",
|
||||
"/cp/",
|
||||
"/checkout/",
|
||||
],
|
||||
},
|
||||
],
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
host: SITE_URL,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
// Marketing-launch SEO (mc#1486). App-Router sitemap convention: this
|
||||
// file is served as `/sitemap.xml` and enumerates the public marketing
|
||||
// surface for search crawlers + AI training pipelines.
|
||||
//
|
||||
// Scope deliberately narrow:
|
||||
// - Apex landing, pricing, and the (currently single) blog post.
|
||||
// - Authed app routes are excluded — they're disallowed in robots.ts
|
||||
// and would appear as "Please sign in" wall to a crawler.
|
||||
//
|
||||
// `lastModified` uses a build-time timestamp rather than per-route
|
||||
// fs.stat so the same value applies regardless of where the build
|
||||
// runs (Vercel/Railway/local). When we add CMS-backed blog content,
|
||||
// swap to a per-entry timestamp from the source-of-truth metadata.
|
||||
const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SITE_URL ?? "https://app.moleculesai.app";
|
||||
|
||||
const BUILD_DATE = new Date();
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: `${SITE_URL}/`,
|
||||
lastModified: BUILD_DATE,
|
||||
changeFrequency: "weekly",
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/pricing`,
|
||||
lastModified: BUILD_DATE,
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/blog/2026-04-20-chrome-devtools-mcp`,
|
||||
lastModified: new Date("2026-04-20"),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.6,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -34,6 +34,28 @@ async def list_peers() -> list[dict]:
|
||||
|
||||
async def delegate_task(workspace_id: str, task: str) -> str:
|
||||
"""Send a task to a peer workspace via A2A and return the response text."""
|
||||
# Task #190 / #193 — Self-delegation guard. Without this, a workspace
|
||||
# delegating to its own UUID round-trips through the platform proxy back
|
||||
# into the sender; the synchronous handler waits on the same lock the
|
||||
# caller holds, the request times out, and the platform writes an
|
||||
# a2a_receive activity row with source_id=our own workspace UUID. The
|
||||
# inbox poller then surfaces that row as kind="peer_agent" and the agent
|
||||
# sees the timeout echoed back as a peer instructing it (#190).
|
||||
#
|
||||
# The sibling guards live in:
|
||||
# - workspace-server/internal/handlers/delegation.go (Go API gate)
|
||||
# - workspace/a2a_tools_delegation.py (MCP path guard)
|
||||
# This module is the framework-agnostic adapter surface used by adapters
|
||||
# that don't go through a2a_tools_delegation.py — it needs its own guard.
|
||||
if WORKSPACE_ID and workspace_id == WORKSPACE_ID:
|
||||
return (
|
||||
"Error: self-delegation rejected (cannot delegate_task to your own "
|
||||
"workspace). There is no peer who is also you — the platform proxy "
|
||||
"would deadlock and the timeout would echo back as a peer_agent "
|
||||
"message from yourself (#190). Do the work directly, or use "
|
||||
"commit_memory / send_message_to_user instead."
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
# Discover target URL
|
||||
try:
|
||||
|
||||
@@ -412,6 +412,28 @@ async def delegate_task_async(
|
||||
"""
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
# Task #190 / #193 — Self-delegation guard (async path). Even on the
|
||||
# async path that returns a task_id immediately, _execute_delegation
|
||||
# eventually fires the A2A POST back to our own URL, which times out
|
||||
# against our own held run lock, gets recorded with source_id=our
|
||||
# workspace UUID, and surfaces in the inbox as a peer_agent message
|
||||
# from ourselves (#190). Reject before scheduling the background task
|
||||
# so no peer_agent echo can be generated. Sibling guards:
|
||||
# - workspace-server/internal/handlers/delegation.go (Go API gate)
|
||||
# - workspace/a2a_tools_delegation.py (MCP sync + async paths)
|
||||
# - workspace/builtin_tools/a2a_tools.py (framework-agnostic sync)
|
||||
if WORKSPACE_ID and workspace_id == WORKSPACE_ID:
|
||||
log_event(event_type="delegation", action="delegate", resource=workspace_id,
|
||||
outcome="rejected_self_delegation", trace_id=task_id)
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
"self-delegation rejected: cannot delegate_task_async to your "
|
||||
"own workspace (would time out and echo back as a peer_agent "
|
||||
"message from yourself — #190)"
|
||||
),
|
||||
}
|
||||
|
||||
# RBAC check
|
||||
roles, custom_perms = get_workspace_roles()
|
||||
if not check_permission("delegate", roles, custom_perms):
|
||||
|
||||
+24
-1
@@ -102,11 +102,34 @@ class InboxMessage:
|
||||
arrival_workspace_id: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
# Task #190 / #193 — Distinguish delegation-result rows from peer-agent
|
||||
# messages. The platform's pushDelegationResultToInbox (RFC #2829 PR-2)
|
||||
# writes activity_type='a2a_receive' with method='delegate_result' and
|
||||
# source_id=our own workspace UUID, so the caller's inbox poller can
|
||||
# surface delegation completions/failures via wait_for_message. But
|
||||
# the default to_dict derives kind="peer_agent" purely from peer_id
|
||||
# being non-empty — which makes a synchronous-delegation timeout, or
|
||||
# a cross-workspace ProxyA2A failure, appear to the agent as a NEW
|
||||
# peer_agent message from our own workspace UUID (#190 self-echo).
|
||||
#
|
||||
# Explicitly classify rows with method='delegate_result' as
|
||||
# kind='delegation_result' regardless of peer_id, so:
|
||||
# 1. wait_for_message gives the original caller a structured
|
||||
# delegation result (not a fake peer instruction).
|
||||
# 2. Agents reading the envelope don't mistake the row for a
|
||||
# peer instructing them — preventing the #190 reply-via-
|
||||
# delegate_task-to-self loop.
|
||||
if self.method == "delegate_result":
|
||||
kind = "delegation_result"
|
||||
elif self.peer_id:
|
||||
kind = "peer_agent"
|
||||
else:
|
||||
kind = "canvas_user"
|
||||
d = {
|
||||
"activity_id": self.activity_id,
|
||||
"text": self.text,
|
||||
"peer_id": self.peer_id,
|
||||
"kind": "peer_agent" if self.peer_id else "canvas_user",
|
||||
"kind": kind,
|
||||
"method": self.method,
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
@@ -325,3 +325,58 @@ class TestGetPeersSummary:
|
||||
|
||||
result = await mod.get_peers_summary()
|
||||
assert result == "No peers available."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Self-delegation guard (Task #190 / #193)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSelfDelegationGuard:
|
||||
"""delegate_task to your own workspace UUID must be rejected BEFORE any
|
||||
discovery / proxy hop. Otherwise the request round-trips back to us,
|
||||
deadlocks on the run lock, times out, and surfaces in the inbox as a
|
||||
peer_agent message from our own workspace (the documented #190 self-echo
|
||||
bug)."""
|
||||
|
||||
async def test_delegate_task_rejects_self(self, monkeypatch):
|
||||
mod = _load_a2a_tools(monkeypatch, workspace_id="ws-self-abc")
|
||||
|
||||
calls = []
|
||||
|
||||
class TrappingClient:
|
||||
def __init__(self, timeout): pass
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *a): pass
|
||||
async def get(self, *a, **kw):
|
||||
calls.append(("get", a, kw))
|
||||
raise AssertionError("guard must reject before discover")
|
||||
async def post(self, *a, **kw):
|
||||
calls.append(("post", a, kw))
|
||||
raise AssertionError("guard must reject before proxy POST")
|
||||
|
||||
monkeypatch.setattr(mod.httpx, "AsyncClient", TrappingClient)
|
||||
|
||||
result = await mod.delegate_task("ws-self-abc", "do a thing")
|
||||
assert "self-delegation" in result.lower()
|
||||
assert not calls, "no HTTP call should be made for self-delegation"
|
||||
|
||||
async def test_delegate_task_allows_real_peer(self, monkeypatch):
|
||||
"""Guard is strictly equality on WORKSPACE_ID — a different target
|
||||
passes through to the normal discover/proxy path."""
|
||||
mod = _load_a2a_tools(monkeypatch, workspace_id="ws-self-abc")
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, timeout): pass
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *a): pass
|
||||
async def get(self, url, headers=None):
|
||||
return _FakeResponse(200, {"url": "http://target.test/a2a"})
|
||||
async def post(self, url, json=None, headers=None):
|
||||
return _FakeResponse(200, {
|
||||
"result": {"parts": [{"kind": "text", "text": "ok"}]}
|
||||
})
|
||||
|
||||
monkeypatch.setattr(mod.httpx, "AsyncClient", FakeClient)
|
||||
|
||||
result = await mod.delegate_task("ws-DIFFERENT-xyz", "do a thing")
|
||||
assert "self-delegation" not in result.lower()
|
||||
|
||||
@@ -148,6 +148,41 @@ class TestRBAC:
|
||||
assert "RBAC" in result["error"]
|
||||
|
||||
|
||||
class TestSelfDelegationGuard:
|
||||
"""Task #190 / #193 — delegate_task_async must reject delegation to the
|
||||
caller's own workspace BEFORE scheduling the background task. Otherwise
|
||||
the platform A2A round-trip times out against our own held run lock, the
|
||||
failure is logged with source_id=our workspace UUID, and the inbox
|
||||
poller surfaces the row as a peer_agent message from ourselves."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_path_rejects_self_workspace(self, delegation_mocks):
|
||||
mod, *_ = delegation_mocks
|
||||
# WORKSPACE_ID was set to "ws-self" by the fixture's monkeypatch.
|
||||
# The module reads it at import time → reload-equivalent comparison.
|
||||
mod.WORKSPACE_ID = "ws-self"
|
||||
|
||||
result = await _invoke(mod, workspace_id="ws-self")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "self-delegation" in result["error"].lower()
|
||||
# No background task should have been scheduled.
|
||||
assert len(mod._background_tasks) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_path_allows_different_workspace(self, delegation_mocks):
|
||||
"""Guard does NOT short-circuit a real peer target."""
|
||||
mod, *_ = delegation_mocks
|
||||
mod.WORKSPACE_ID = "ws-self"
|
||||
_, mock_cls = _make_mock_client()
|
||||
|
||||
with patch("httpx.AsyncClient", mock_cls):
|
||||
result = await _invoke(mod, workspace_id="ws-peer")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["status"] == "delegated"
|
||||
|
||||
|
||||
class TestAsyncDelegation:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -131,6 +131,36 @@ def test_message_from_activity_peer_agent():
|
||||
assert msg.to_dict()["kind"] == "peer_agent"
|
||||
|
||||
|
||||
def test_message_from_activity_delegate_result_distinct_kind():
|
||||
"""Task #190 / #193 — pushDelegationResultToInbox (RFC #2829 PR-2) writes
|
||||
rows with method='delegate_result' and source_id=our own workspace UUID
|
||||
so the caller's wait_for_message can surface delegation completions or
|
||||
failures. Without an explicit kind override, to_dict() would classify
|
||||
those rows as kind='peer_agent' (peer_id non-empty) and the agent would
|
||||
treat its OWN delegation timeout as a peer instructing it — the #190
|
||||
self-echo bug. Classify these rows as kind='delegation_result' so they
|
||||
are recognizable as structured delegation outcomes."""
|
||||
row = {
|
||||
"id": "act-90",
|
||||
"source_id": "ws-self-abc", # same as our workspace
|
||||
"method": "delegate_result",
|
||||
"summary": "Delegation failed",
|
||||
"response_body": {"text": "polling timeout", "delegation_id": "d-1"},
|
||||
"created_at": "2026-05-18T00:00:00Z",
|
||||
}
|
||||
msg = inbox.message_from_activity(row)
|
||||
payload = msg.to_dict()
|
||||
assert payload["kind"] == "delegation_result", (
|
||||
f"delegate_result rows must surface as kind='delegation_result', "
|
||||
f"not peer_agent (got {payload['kind']!r})"
|
||||
)
|
||||
# Method preserved for downstream consumers that key off it.
|
||||
assert payload["method"] == "delegate_result"
|
||||
# peer_id is still set on the dataclass for back-compat dispatch — the
|
||||
# distinguishing signal is the kind field.
|
||||
assert msg.peer_id == "ws-self-abc"
|
||||
|
||||
|
||||
def test_message_from_activity_handles_string_request_body():
|
||||
row = {
|
||||
"id": "act-3",
|
||||
|
||||
Reference in New Issue
Block a user