Merge pull request #2625 from Molecule-AI/staging
staging → main: auto-promote 9fd52e9
This commit is contained in:
commit
e4db4cfb11
@ -169,7 +169,17 @@ export default async function globalSetup(_config: FullConfig): Promise<void> {
|
||||
orgID = row.id;
|
||||
return true;
|
||||
}
|
||||
if (row.instance_status === "failed") throw new Error(`provision failed: ${slug}`);
|
||||
if (row.instance_status === "failed") {
|
||||
// Dump every diagnostic field the admin row carries — boot stage,
|
||||
// last error, terraform/SSM state, etc. The bare slug message used
|
||||
// to surface ZERO context, so triaging a failed provision meant
|
||||
// re-running locally to repro. Now the failure log carries enough
|
||||
// to point at the right subsystem (CP/AWS/SSM/runtime) without a
|
||||
// second round-trip.
|
||||
throw new Error(
|
||||
`provision failed: ${slug} — admin-orgs row: ${JSON.stringify(row)}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
PROVISION_TIMEOUT_MS,
|
||||
@ -249,7 +259,17 @@ export default async function globalSetup(_config: FullConfig): Promise<void> {
|
||||
if (r.status !== 200) return null;
|
||||
if (r.body?.status === "online") return true;
|
||||
if (r.body?.status === "failed") {
|
||||
throw new Error(`Workspace failed: ${r.body.last_sample_error || ""}`);
|
||||
// last_sample_error is often empty when the failure happens before
|
||||
// the agent emits a sample (e.g. boot crash, image pull error,
|
||||
// missing PYTHONPATH, OpenAI quota at startup). Dumping the full
|
||||
// body gives triage the boot_stage / last_error / image fields it
|
||||
// needs without a second probe. Otherwise this propagates as a
|
||||
// bare "Workspace failed: " — the exact useless message that
|
||||
// sent #2632 to the issue tracker.
|
||||
const detail = r.body.last_sample_error
|
||||
? r.body.last_sample_error
|
||||
: `(no last_sample_error) full body: ${JSON.stringify(r.body)}`;
|
||||
throw new Error(`Workspace failed: ${detail}`);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
@ -73,14 +73,19 @@ export function ApprovalBanner() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDecide(approval, "approved")}
|
||||
className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-xs rounded-lg text-white font-medium transition-colors"
|
||||
// Hover DARKER not lighter — emerald-500 on white text
|
||||
// drops contrast vs emerald-700.
|
||||
className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-xs rounded-lg text-white font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-emerald-400/70"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDecide(approval, "denied")}
|
||||
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-xs rounded-lg text-ink-mid transition-colors"
|
||||
// Was a no-op hover (`bg-surface-card hover:bg-surface-card`).
|
||||
// Lift to surface-elevated on hover so the button visibly
|
||||
// responds before a destructive deny.
|
||||
className="px-3 py-1.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-xs rounded-lg text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-amber-400/70"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
|
||||
@ -91,12 +91,15 @@ export function ConfirmDialog({
|
||||
|
||||
if (!open || !mounted) return null;
|
||||
|
||||
// Hover goes DARKER, not lighter — lighter shades on white text drop
|
||||
// contrast below AA on the accent and red ramps. Darker hovers stay
|
||||
// readable in both light and dark themes.
|
||||
const confirmColors =
|
||||
confirmVariant === "danger"
|
||||
? "bg-red-600 hover:bg-red-500 text-white"
|
||||
? "bg-red-600 hover:bg-red-700 text-white"
|
||||
: confirmVariant === "warning"
|
||||
? "bg-amber-600 hover:bg-amber-500 text-white"
|
||||
: "bg-accent-strong hover:bg-accent text-white";
|
||||
? "bg-amber-600 hover:bg-amber-700 text-white"
|
||||
: "bg-accent hover:bg-accent-strong text-white";
|
||||
|
||||
// Render via Portal so the fixed-position dialog escapes any containing block
|
||||
// (e.g. parents with transform, filter, will-change that break position:fixed).
|
||||
@ -123,7 +126,7 @@ export function ConfirmDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-3.5 py-1.5 text-[13px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
|
||||
className="px-3.5 py-1.5 text-[13px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-elevated border border-line hover:border-line-soft rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@ -131,7 +134,7 @@ export function ConfirmDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors ${confirmColors}`}
|
||||
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken focus-visible:ring-accent/60 ${confirmColors}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
|
||||
@ -40,6 +40,22 @@ export interface ExternalConnectionInfo {
|
||||
// + inbound. Optional for backward compat with platforms that
|
||||
// haven't shipped PR #2413 yet.
|
||||
universal_mcp_snippet?: string;
|
||||
// Hermes channel snippet — for operators whose external agent IS a
|
||||
// hermes-agent session. Routes A2A traffic into the hermes gateway
|
||||
// via the molecule-channel plugin (Molecule-AI/hermes-channel-molecule).
|
||||
// Long-poll based (no tunnel) — same UX shape as the Claude Code
|
||||
// channel tab. Gives hermes true push parity. Optional for backward
|
||||
// compat with platforms that haven't shipped this PR yet.
|
||||
hermes_channel_snippet?: string;
|
||||
// Codex MCP config snippet — wires the molecule MCP server into
|
||||
// ~/.codex/config.toml so codex agents can call platform tools.
|
||||
// Outbound-tools-only today (codex's MCP client doesn't route
|
||||
// notifications/*); push parity would need a separate bridge daemon.
|
||||
codex_snippet?: string;
|
||||
// OpenClaw MCP config snippet — wires molecule MCP + starts the
|
||||
// openclaw gateway on loopback. Outbound-tools-only today; push
|
||||
// parity on an external openclaw needs a sessions.steer bridge.
|
||||
openclaw_snippet?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@ -47,13 +63,21 @@ interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Tab = "python" | "curl" | "claude" | "mcp" | "fields";
|
||||
type Tab = "python" | "curl" | "claude" | "mcp" | "hermes" | "codex" | "openclaw" | "fields";
|
||||
|
||||
export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
// Default to Claude Code when the platform offers it — that's the
|
||||
// newest + simplest path (no tunnel needed). Falls back to Python
|
||||
// for older platform builds that don't ship the snippet.
|
||||
const initialTab: Tab = info?.claude_code_channel_snippet ? "claude" : "python";
|
||||
// Default to Universal MCP when the platform offers it — runtime-
|
||||
// agnostic outbound tool path that works for any MCP-aware runtime
|
||||
// (Claude Code, hermes, codex, etc.) and lets operators inspect the
|
||||
// primitives before picking a runtime-specific tab. Python SDK is
|
||||
// the fallback for platforms predating the universal_mcp_snippet
|
||||
// field. Pre-2026-05-03 the default was "claude" (Claude Code first)
|
||||
// but operators using non-Claude runtimes opened to a tab they had
|
||||
// to skip past — universal MCP works for everyone as a starting
|
||||
// point and the runtime-specific tabs are still one click away.
|
||||
const initialTab: Tab = info?.universal_mcp_snippet
|
||||
? "mcp"
|
||||
: "python";
|
||||
const [tab, setTab] = useState<Tab>(initialTab);
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
|
||||
@ -108,6 +132,24 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`MOLECULE_WORKSPACE_TOKEN="${info.auth_token}"`,
|
||||
);
|
||||
// Hermes channel snippet uses MOLECULE_WORKSPACE_TOKEN (same env-var
|
||||
// name as Universal MCP). Stamp the auth_token in so the operator's
|
||||
// copy-paste is fully ready-to-run.
|
||||
const filledHermes = info.hermes_channel_snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`MOLECULE_WORKSPACE_TOKEN="${info.auth_token}"`,
|
||||
);
|
||||
// Codex + OpenClaw snippets carry the placeholder inside the
|
||||
// generated config block (TOML / JSON respectively). Stamp the
|
||||
// token in so the copy-paste is one less manual edit.
|
||||
const filledCodex = info.codex_snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"',
|
||||
`MOLECULE_WORKSPACE_TOKEN = "${info.auth_token}"`,
|
||||
);
|
||||
const filledOpenClaw = info.openclaw_snippet?.replace(
|
||||
'WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`WORKSPACE_TOKEN="${info.auth_token}"`,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
|
||||
@ -135,10 +177,18 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
// SDK second (full register+heartbeat+inbound); Universal
|
||||
// MCP third (any MCP-aware runtime, outbound-only); curl
|
||||
// for one-shot register; Fields for raw values.
|
||||
// Tab order: Universal MCP first (default, runtime-
|
||||
// agnostic primitives), then runtime-specific channel/
|
||||
// SDK tabs, then curl + Fields. Each runtime tab only
|
||||
// appears when the platform supplies the snippet — no
|
||||
// dead "tab missing snippet" UX.
|
||||
const tabs: Tab[] = [];
|
||||
if (filledChannel) tabs.push("claude");
|
||||
tabs.push("python");
|
||||
if (filledUniversalMcp) tabs.push("mcp");
|
||||
tabs.push("python");
|
||||
if (filledChannel) tabs.push("claude");
|
||||
if (filledHermes) tabs.push("hermes");
|
||||
if (filledCodex) tabs.push("codex");
|
||||
if (filledOpenClaw) tabs.push("openclaw");
|
||||
tabs.push("curl", "fields");
|
||||
return tabs;
|
||||
})().map((t) => (
|
||||
@ -156,6 +206,12 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
>
|
||||
{t === "claude"
|
||||
? "Claude Code"
|
||||
: t === "hermes"
|
||||
? "Hermes"
|
||||
: t === "codex"
|
||||
? "Codex"
|
||||
: t === "openclaw"
|
||||
? "OpenClaw"
|
||||
: t === "python"
|
||||
? "Python SDK"
|
||||
: t === "mcp"
|
||||
@ -205,6 +261,33 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
onCopy={() => copy(filledUniversalMcp, "mcp")}
|
||||
/>
|
||||
)}
|
||||
{tab === "hermes" && filledHermes && (
|
||||
<SnippetBlock
|
||||
value={filledHermes}
|
||||
label="Hermes channel — bridges this workspace's A2A traffic into your hermes-agent session as platform messages (push parity with Claude Code). Long-poll based; no tunnel needed."
|
||||
copyKey="hermes"
|
||||
copied={copiedKey === "hermes"}
|
||||
onCopy={() => copy(filledHermes, "hermes")}
|
||||
/>
|
||||
)}
|
||||
{tab === "codex" && filledCodex && (
|
||||
<SnippetBlock
|
||||
value={filledCodex}
|
||||
label="Codex MCP config — wires the molecule MCP server into ~/.codex/config.toml. Outbound tools today; inbound A2A push needs the Python SDK tab paired in (codex's MCP runtime doesn't route arbitrary notifications/* yet)."
|
||||
copyKey="codex"
|
||||
copied={copiedKey === "codex"}
|
||||
onCopy={() => copy(filledCodex, "codex")}
|
||||
/>
|
||||
)}
|
||||
{tab === "openclaw" && filledOpenClaw && (
|
||||
<SnippetBlock
|
||||
value={filledOpenClaw}
|
||||
label="OpenClaw MCP config — wires the molecule MCP server via openclaw mcp set + starts the gateway on loopback. Outbound tools today; inbound A2A push on an external openclaw needs the Python SDK tab paired in (a sessions.steer bridge daemon is future work)."
|
||||
copyKey="openclaw"
|
||||
copied={copiedKey === "openclaw"}
|
||||
onCopy={() => copy(filledOpenClaw, "openclaw")}
|
||||
/>
|
||||
)}
|
||||
{tab === "fields" && (
|
||||
<div className="space-y-2">
|
||||
<Field label="workspace_id" value={info.workspace_id} onCopy={() => copy(info.workspace_id, "wsid")} copied={copiedKey === "wsid"} />
|
||||
|
||||
@ -38,6 +38,18 @@ export function Toaster() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Esc dismisses the newest toast — keyboard parity with the × button.
|
||||
// Errors never auto-expire, so without this a keyboard-only user has to
|
||||
// tab through the entire app to reach the dismiss button on a stuck error.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
setToasts((prev) => (prev.length === 0 ? prev : prev.slice(0, -1)));
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const toastCls = (type: Toast["type"]) =>
|
||||
`flex items-center gap-2 pl-4 pr-2 py-2.5 rounded-xl shadow-2xl shadow-black/40 text-sm backdrop-blur-md animate-in slide-in-from-bottom duration-200 ${
|
||||
type === "success"
|
||||
@ -47,6 +59,17 @@ export function Toaster() {
|
||||
: "bg-surface-sunken/90 border border-line/40 text-ink"
|
||||
}`;
|
||||
|
||||
// Success/error toasts are intentionally dark in both themes (high-vis).
|
||||
// Info uses the semantic surface that flips with theme — so the dismiss
|
||||
// button needs a tint that stays visible on a light bg in light mode.
|
||||
const dismissCls = (type: Toast["type"]) => {
|
||||
const base =
|
||||
"ml-1 w-7 h-7 inline-flex items-center justify-center text-base leading-none rounded transition-colors opacity-70 hover:opacity-100 focus-visible:opacity-100 focus:outline-none focus-visible:ring-2 shrink-0";
|
||||
return type === "info"
|
||||
? `${base} hover:bg-ink/10 focus-visible:ring-accent/60`
|
||||
: `${base} hover:bg-white/15 focus-visible:ring-white/70`;
|
||||
};
|
||||
|
||||
const pos =
|
||||
"fixed bottom-16 left-1/2 -translate-x-1/2 z-[80] flex flex-col gap-2 items-center";
|
||||
|
||||
@ -66,7 +89,7 @@ export function Toaster() {
|
||||
type="button"
|
||||
onClick={() => dismiss(toast.id)}
|
||||
aria-label="Dismiss notification"
|
||||
className="ml-1 p-1 rounded hover:bg-surface-card/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
|
||||
className={dismissCls(toast.type)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@ -94,7 +117,7 @@ export function Toaster() {
|
||||
type="button"
|
||||
onClick={() => dismiss(toast.id)}
|
||||
aria-label="Dismiss notification"
|
||||
className="ml-1 p-1 rounded hover:bg-surface-card/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
|
||||
className={dismissCls(toast.type)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
@ -22,6 +22,24 @@ export function Tooltip({ text, children }: Props) {
|
||||
|
||||
useEffect(() => () => clearTimeout(timerRef.current), []);
|
||||
|
||||
// WCAG 1.4.13 (Content on Hover or Focus) — Dismissible: a mechanism
|
||||
// is available to dismiss the additional content WITHOUT moving
|
||||
// pointer hover or keyboard focus. Esc dismisses while the trigger
|
||||
// stays focused/hovered, so a screen-magnifier user can read what
|
||||
// the tooltip was covering without losing their place.
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
clearTimeout(timerRef.current);
|
||||
setShow(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, [show]);
|
||||
|
||||
const enter = useCallback(() => {
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (triggerRef.current) {
|
||||
|
||||
82
canvas/src/components/__tests__/Toaster.test.tsx
Normal file
82
canvas/src/components/__tests__/Toaster.test.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { Toaster, showToast } from "../Toaster";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Toaster keyboard a11y", () => {
|
||||
it("Esc dismisses the most recent toast", () => {
|
||||
render(<Toaster />);
|
||||
act(() => {
|
||||
showToast("first", "info");
|
||||
showToast("second", "info");
|
||||
});
|
||||
expect(screen.getByText("first")).toBeTruthy();
|
||||
expect(screen.getByText("second")).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
});
|
||||
expect(screen.queryByText("second")).toBeNull();
|
||||
expect(screen.getByText("first")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Esc dismisses persistent error toasts", () => {
|
||||
render(<Toaster />);
|
||||
act(() => {
|
||||
showToast("boom", "error");
|
||||
});
|
||||
expect(screen.getByText("boom")).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
});
|
||||
expect(screen.queryByText("boom")).toBeNull();
|
||||
});
|
||||
|
||||
it("Esc with no toasts is a no-op", () => {
|
||||
render(<Toaster />);
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
});
|
||||
// no throw, nothing rendered
|
||||
expect(screen.queryAllByRole("button", { name: "Dismiss notification" })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("dismiss button has accessible label and is keyboard reachable", () => {
|
||||
render(<Toaster />);
|
||||
act(() => {
|
||||
showToast("hi", "info");
|
||||
});
|
||||
const btn = screen.getByRole("button", { name: "Dismiss notification" });
|
||||
expect(btn).toBeTruthy();
|
||||
// Native <button> defaults to keyboard-focusable; explicit assertion guards
|
||||
// against a future regression where someone adds tabindex=-1.
|
||||
expect(btn.getAttribute("tabindex")).not.toBe("-1");
|
||||
});
|
||||
|
||||
it("dismiss button click removes that specific toast", () => {
|
||||
render(<Toaster />);
|
||||
act(() => {
|
||||
showToast("a", "info");
|
||||
showToast("b", "info");
|
||||
});
|
||||
const buttons = screen.getAllByRole("button", { name: "Dismiss notification" });
|
||||
expect(buttons).toHaveLength(2);
|
||||
|
||||
// Click the first dismiss → "a" goes away, "b" stays
|
||||
act(() => {
|
||||
fireEvent.click(buttons[0]);
|
||||
});
|
||||
expect(screen.queryByText("a")).toBeNull();
|
||||
expect(screen.getByText("b")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -792,7 +792,20 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
}`}
|
||||
>
|
||||
{msg.content && (
|
||||
<div className={`prose prose-sm max-w-none [&>p]:mb-1 [&>p:last-child]:mb-0 ${msg.role === "user" ? "prose-invert" : ""}`}>
|
||||
<div
|
||||
className={`prose prose-sm max-w-none [&>p]:mb-1 [&>p:last-child]:mb-0 ${
|
||||
msg.role === "user"
|
||||
? "prose-invert"
|
||||
// Agent bubbles in dark mode: invert prose AND brighten
|
||||
// the body/heading/bold/code tokens. prose-invert's
|
||||
// default `--tw-prose-invert-body: zinc-300` lands at
|
||||
// ~5.3:1 against bg-zinc-700 — passes AA but reads
|
||||
// washed out next to the user bubble's crisp
|
||||
// white-on-blue (~10:1). Push body to zinc-100 so the
|
||||
// agent text matches that crispness.
|
||||
: "dark:prose-invert dark:[--tw-prose-invert-body:theme(colors.zinc.100)] dark:[--tw-prose-invert-headings:theme(colors.white)] dark:[--tw-prose-invert-bold:theme(colors.white)] dark:[--tw-prose-invert-code:theme(colors.zinc.100)]"
|
||||
}`}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -574,12 +574,22 @@ function NormalMessage({ msg }: { msg: CommMessage }) {
|
||||
{msg.flow === "out" ? `→ To ${msg.peerName}` : `← From ${msg.peerName}`}
|
||||
</div>
|
||||
{msg.text ? (
|
||||
<MarkdownBody className="text-ink-mid">{msg.text}</MarkdownBody>
|
||||
// Outgoing bubble (cyan-900) is dark in both themes → prose-invert default.
|
||||
// Incoming bubble (surface-card) themes light → only invert in dark.
|
||||
<MarkdownBody
|
||||
className="text-ink-mid"
|
||||
invert={msg.flow === "out" ? "always" : "dark-only"}
|
||||
>
|
||||
{msg.text}
|
||||
</MarkdownBody>
|
||||
) : (
|
||||
<div className="text-ink-mid">(no message text)</div>
|
||||
)}
|
||||
{msg.responseText && (
|
||||
<MarkdownBody className="mt-1.5 pt-1.5 border-t border-line/30 text-ink-mid">
|
||||
<MarkdownBody
|
||||
className="mt-1.5 pt-1.5 border-t border-line/30 text-ink-mid"
|
||||
invert={msg.flow === "out" ? "always" : "dark-only"}
|
||||
>
|
||||
{msg.responseText}
|
||||
</MarkdownBody>
|
||||
)}
|
||||
@ -706,17 +716,29 @@ function ErrorMessage({ msg }: { msg: CommMessage }) {
|
||||
* prose tweaks that keep paragraphs tight inside a small bubble.
|
||||
* Code blocks get an `overflow-x-auto` so a long line of code doesn't
|
||||
* blow out the bubble's max-width — agent-to-agent replies routinely
|
||||
* ship code samples and JSON. */
|
||||
* ship code samples and JSON.
|
||||
*
|
||||
* `invert` controls the prose color flip:
|
||||
* - "always": container bg is dark in BOTH themes (cyan-900, red-950),
|
||||
* so prose always wants light body text.
|
||||
* - "dark-only": container bg uses a theming token that goes light in
|
||||
* light mode (e.g. bg-surface-card). Prose only inverts in dark
|
||||
* mode; light mode keeps default dark prose colors against the
|
||||
* light bg. Without this, light mode rendered light text on light
|
||||
* bg = invisible markdown. */
|
||||
function MarkdownBody({
|
||||
children,
|
||||
className,
|
||||
invert = "always",
|
||||
}: {
|
||||
children: string;
|
||||
className?: string;
|
||||
invert?: "always" | "dark-only";
|
||||
}) {
|
||||
const proseInvert = invert === "always" ? "prose-invert" : "dark:prose-invert";
|
||||
return (
|
||||
<div
|
||||
className={`prose prose-sm prose-invert max-w-none [&>p]:mb-1 [&>p:last-child]:mb-0 [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto ${className ?? ""}`}
|
||||
className={`prose prose-sm ${proseInvert} max-w-none [&>p]:mb-1 [&>p:last-child]:mb-0 [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto ${className ?? ""}`}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
@ -429,6 +429,42 @@ for wid in $WS_TO_CHECK; do
|
||||
ok " $wid online"
|
||||
done
|
||||
|
||||
# ─── 7b. Canvas-terminal diagnose (EIC chain probe) ────────────────────
|
||||
# This step exists because the canvas-terminal failure of 2026-05-03
|
||||
# was structurally invisible to local-dev (handleLocalConnect uses
|
||||
# docker exec; handleRemoteConnect uses EIC + ssh). The CP provisioner
|
||||
# shipped without the tcp/22 EIC ingress rule for ~6 months and nobody
|
||||
# noticed until a paying tenant clicked Terminal in canvas. Probing the
|
||||
# diagnose endpoint here at synth-E2E time means a regression in
|
||||
# - tenantIngressRules / workspaceIngressRules (CP)
|
||||
# - eicSSHIngressRule helper (CP)
|
||||
# - AuthorizeIngress source-group support (CP awsapi)
|
||||
# - EIC_ENDPOINT_SG_ID Railway env
|
||||
# - handleRemoteConnect's send-ssh-public-key/open-tunnel/ssh chain
|
||||
# surfaces within ~20 min of merge instead of waiting for a user report.
|
||||
#
|
||||
# The diagnose endpoint runs the full EIC + ssh probe from inside the
|
||||
# tenant's workspace-server (which already has AWS creds via its IAM
|
||||
# profile) and reports per-step status. We only need to call it as the
|
||||
# tenant — no AWS creds needed on the GHA runner. Returns
|
||||
# {"ok": bool, "first_failure": "name", "steps": [...]}.
|
||||
#
|
||||
# Local-docker workspaces (instance_id NULL) get diagnoseLocal which
|
||||
# probes docker.Ping + container exec; we still expect ok=true there
|
||||
# since local-docker is the alternative production path.
|
||||
log "7b/11 Canvas-terminal EIC diagnose probe..."
|
||||
for wid in $WS_TO_CHECK; do
|
||||
DIAG_JSON=$(tenant_call GET "/workspaces/$wid/terminal/diagnose" 2>/dev/null || echo '{}')
|
||||
DIAG_OK=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print('true' if d.get('ok') else 'false')" 2>/dev/null || echo "false")
|
||||
if [ "$DIAG_OK" = "true" ]; then
|
||||
ok " $wid terminal-reachable (canvas terminal will work)"
|
||||
else
|
||||
DIAG_FAIL=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('first_failure','unknown'))" 2>/dev/null || echo "unknown")
|
||||
DIAG_DETAIL=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); s=[x for x in d.get('steps',[]) if not x.get('ok')]; print(s[0].get('error','') if s else '')" 2>/dev/null || echo "")
|
||||
fail "Workspace $wid terminal diagnose failed at step '$DIAG_FAIL': $DIAG_DETAIL — check tenant SG has tcp/22 from EIC endpoint SG (sg-0785d5c6138220523), EIC_ENDPOINT_SG_ID set in Railway, and EIC endpoint health"
|
||||
fi
|
||||
done
|
||||
|
||||
# ─── 8. A2A round-trip on parent ───────────────────────────────────────
|
||||
log "8/11 Sending A2A message to parent — expecting agent response..."
|
||||
# Smoke prompt phrasing — DO NOT trim back to the bare "Reply with exactly: PONG"
|
||||
|
||||
@ -186,3 +186,191 @@ async def main():
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
`
|
||||
|
||||
// externalHermesChannelTemplate — install snippet for operators whose
|
||||
// external agent IS a hermes-agent session. Routes the workspace's
|
||||
// A2A traffic into the running hermes gateway as platform messages
|
||||
// via the molecule-channel plugin.
|
||||
//
|
||||
// The plugin (Molecule-AI/hermes-channel-molecule) is a hermes
|
||||
// platform adapter that:
|
||||
// 1. Spawns ``python -m molecule_runtime.a2a_mcp_server`` as a
|
||||
// stdio MCP subprocess (separate from any hermes-side MCP
|
||||
// client connection).
|
||||
// 2. Long-polls ``wait_for_message`` on the platform's inbox.
|
||||
// 3. Dispatches each inbound activity into the hermes gateway as a
|
||||
// MessageEvent — same code path Telegram/Discord use.
|
||||
// 4. Outbound replies route via ``send_message_to_user`` (canvas
|
||||
// user) or ``delegate_task`` (peer agent) MCP tool calls.
|
||||
//
|
||||
// Result: hermes gets push parity with Claude Code / codex / openclaw —
|
||||
// canvas messages and peer A2A arrive as conversation turns mid-session,
|
||||
// not just at the start of a new ``hermes`` invocation.
|
||||
//
|
||||
// Plugin uses the upstream ``register_platform`` API shipped by
|
||||
// NousResearch/hermes-agent#17751 (merged 2026-04-30) and falls back
|
||||
// to the legacy ``register_platform_adapter`` shape on older forks —
|
||||
// same wheel installs cleanly on stock or patched hermes-agent.
|
||||
const externalHermesChannelTemplate = `# Hermes channel — bridges this workspace's A2A traffic into your
|
||||
# hermes-agent session. No tunnel/public URL needed (long-poll based,
|
||||
# same shape as the Claude Code channel).
|
||||
#
|
||||
# Prereq: a hermes-agent install on the target machine. Latest builds
|
||||
# (post #17751) ship the platform-plugin API natively; older ones are
|
||||
# also supported via the plugin's dual-mode fallback.
|
||||
#
|
||||
# 1. Install the runtime + plugin:
|
||||
pip install molecule-ai-workspace-runtime
|
||||
pip install 'git+https://github.com/Molecule-AI/hermes-channel-molecule.git'
|
||||
|
||||
# 2. Export the workspace credentials:
|
||||
export MOLECULE_WORKSPACE_ID={{WORKSPACE_ID}}
|
||||
export MOLECULE_PLATFORM_URL={{PLATFORM_URL}}
|
||||
export MOLECULE_WORKSPACE_TOKEN="<paste from create response>"
|
||||
export MOLECULE_ORG_ID="<your org id>"
|
||||
|
||||
# 3. Edit ~/.hermes/config.yaml — under your existing top-level
|
||||
# gateway: block, add a plugin_platforms entry:
|
||||
#
|
||||
# gateway:
|
||||
# # ...your existing gateway settings...
|
||||
# plugin_platforms:
|
||||
# molecule:
|
||||
# enabled: true
|
||||
#
|
||||
# If you don't yet have a gateway: block, create one with just
|
||||
# that plugin_platforms entry. Don't append blindly — YAML
|
||||
# rejects duplicate top-level keys, so a second gateway: block
|
||||
# will silently break hermes config loading.
|
||||
|
||||
# 4. Restart the hermes gateway:
|
||||
hermes gateway --replace
|
||||
|
||||
# Inbound canvas messages + peer A2A now arrive as MessageEvents —
|
||||
# same dispatch path Telegram/Discord/Slack use. The agent replies via
|
||||
# send_message_to_user / delegate_task MCP tool calls (already wired
|
||||
# by the plugin's molecule_runtime MCP subprocess).
|
||||
#
|
||||
# Source + issue tracker:
|
||||
# https://github.com/Molecule-AI/hermes-channel-molecule
|
||||
`
|
||||
|
||||
// externalCodexTemplate — for operators whose external agent is a
|
||||
// codex CLI (@openai/codex) session. Wires the molecule_runtime A2A
|
||||
// MCP server into codex's config.toml so the agent can call
|
||||
// list_peers / delegate_task / send_message_to_user / commit_memory.
|
||||
//
|
||||
// Push parity caveat: codex's MCP client doesn't forward arbitrary
|
||||
// notifications/* from configured MCP servers (verified by reading
|
||||
// codex-rs/codex-mcp/src/connection_manager.rs in openai/codex). So
|
||||
// this snippet gives outbound tools but NOT mid-turn push from
|
||||
// inbound A2A. For full push parity on a codex external, the
|
||||
// equivalent of hermes-channel-molecule would be needed — a bridge
|
||||
// daemon that long-polls the platform inbox and calls codex's
|
||||
// turn/steer RPC. Tracked separately; this snippet is the
|
||||
// outbound-tool-only first cut.
|
||||
const externalCodexTemplate = `# Codex MCP config — outbound tool path. For operators whose external
|
||||
# agent is a codex CLI (@openai/codex) session.
|
||||
#
|
||||
# This wires the molecule platform's A2A MCP server into codex so
|
||||
# the agent can call list_peers / delegate_task / send_message_to_user
|
||||
# / commit_memory. Inbound A2A (canvas messages, peer-initiated tasks)
|
||||
# does NOT push into the running codex turn yet — codex's MCP runtime
|
||||
# doesn't route arbitrary notifications/* from configured MCP servers.
|
||||
# For inbound delivery into a codex session, pair with the Python SDK
|
||||
# tab for now.
|
||||
|
||||
# 1. Install codex CLI + the workspace runtime wheel:
|
||||
npm install -g @openai/codex@^0.57
|
||||
pip install molecule-ai-workspace-runtime
|
||||
|
||||
# 2. Edit ~/.codex/config.toml and add the block below. {{PLATFORM_URL}}
|
||||
# and {{WORKSPACE_ID}} are stamped server-side; paste your auth
|
||||
# token for MOLECULE_WORKSPACE_TOKEN before saving.
|
||||
#
|
||||
# Don't append blindly — TOML rejects duplicate
|
||||
# [mcp_servers.molecule] tables, so re-running on an existing
|
||||
# config will break codex parsing. If [mcp_servers.molecule]
|
||||
# already exists (e.g. you set this up before), replace the
|
||||
# existing block instead of appending.
|
||||
|
||||
mkdir -p ~/.codex
|
||||
# (then open ~/.codex/config.toml in your editor and paste:)
|
||||
#
|
||||
# [mcp_servers.molecule]
|
||||
# command = "python3"
|
||||
# args = ["-m", "molecule_runtime.a2a_mcp_server"]
|
||||
# startup_timeout_sec = 30
|
||||
#
|
||||
# [mcp_servers.molecule.env]
|
||||
# WORKSPACE_ID = "{{WORKSPACE_ID}}"
|
||||
# PLATFORM_URL = "{{PLATFORM_URL}}"
|
||||
# MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"
|
||||
# MOLECULE_ORG_ID = "<your org id>"
|
||||
|
||||
# 3. Run codex — the molecule tools are now available to the agent:
|
||||
codex
|
||||
`
|
||||
|
||||
// externalOpenClawTemplate — for operators whose external agent is an
|
||||
// openclaw session. Wires the molecule MCP server via openclaw's
|
||||
// `mcp set` config + starts the openclaw gateway on loopback.
|
||||
//
|
||||
// Like the codex tab, this is outbound-only. Full push parity on an
|
||||
// external openclaw would need a sessions.steer bridge daemon (the
|
||||
// equivalent of hermes-channel-molecule for openclaw). Tracked
|
||||
// separately; outbound tools is the first cut.
|
||||
const externalOpenClawTemplate = `# OpenClaw MCP config — outbound tool path. For operators whose
|
||||
# external agent is an openclaw session.
|
||||
#
|
||||
# This wires the molecule platform's A2A MCP server into openclaw's
|
||||
# gateway so the agent can call list_peers / delegate_task /
|
||||
# send_message_to_user / commit_memory. Inbound A2A push into a
|
||||
# running openclaw run is not wired here yet — the platform-side
|
||||
# openclaw template (template-openclaw) implements the full
|
||||
# sessions.steer push path; an external setup would need the same
|
||||
# bridge daemon the template uses. For inbound delivery on an
|
||||
# external machine today, pair with the Python SDK tab.
|
||||
|
||||
# 1. Install openclaw CLI + the workspace runtime wheel:
|
||||
npm install -g openclaw@latest
|
||||
pip install molecule-ai-workspace-runtime
|
||||
|
||||
# 2. Onboard openclaw against your model provider (one-time setup).
|
||||
# --non-interactive needs an explicit --provider + --model so it
|
||||
# doesn't prompt; pick what matches your API key. Skip step 2 if
|
||||
# you've already onboarded on this host.
|
||||
#
|
||||
# openclaw onboard --non-interactive \
|
||||
# --provider openai \
|
||||
# --model gpt-5
|
||||
|
||||
# 3. Wire the molecule MCP server. {{WORKSPACE_ID}} + {{PLATFORM_URL}}
|
||||
# are stamped server-side; paste the auth token before running.
|
||||
WORKSPACE_TOKEN="<paste from create response>"
|
||||
MOLECULE_ORG_ID="<your org id>"
|
||||
openclaw mcp set molecule "$(cat <<EOF
|
||||
{
|
||||
"command": "python3",
|
||||
"args": ["-m", "molecule_runtime.a2a_mcp_server"],
|
||||
"env": {
|
||||
"WORKSPACE_ID": "{{WORKSPACE_ID}}",
|
||||
"PLATFORM_URL": "{{PLATFORM_URL}}",
|
||||
"MOLECULE_WORKSPACE_TOKEN": "$WORKSPACE_TOKEN",
|
||||
"MOLECULE_ORG_ID": "$MOLECULE_ORG_ID"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)"
|
||||
|
||||
# 4. Start the openclaw gateway as a durable background process.
|
||||
# A bare '&' dies when the terminal closes; nohup + log file keeps
|
||||
# the gateway alive across logout. For systemd-managed hosts,
|
||||
# register a unit instead.
|
||||
nohup openclaw gateway --dev --port 18789 --bind loopback \
|
||||
> ~/.openclaw/gateway.log 2>&1 &
|
||||
disown
|
||||
|
||||
# 5. Run an agent turn — molecule tools are now available:
|
||||
openclaw agent --message "list my peers"
|
||||
`
|
||||
|
||||
@ -454,6 +454,41 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
"{{PLATFORM_URL}}", platformURL),
|
||||
"{{WORKSPACE_ID}}", id,
|
||||
),
|
||||
// Hermes channel snippet — for operators whose external
|
||||
// agent IS a hermes-agent session. Routes A2A traffic
|
||||
// into the hermes gateway via the molecule-channel
|
||||
// plugin (Molecule-AI/hermes-channel-molecule). Long-
|
||||
// poll based (no tunnel) — same UX as the Claude Code
|
||||
// channel tab. Gives hermes true push parity with the
|
||||
// other runtime templates.
|
||||
"hermes_channel_snippet": strings.ReplaceAll(
|
||||
strings.ReplaceAll(externalHermesChannelTemplate,
|
||||
"{{PLATFORM_URL}}", platformURL),
|
||||
"{{WORKSPACE_ID}}", id,
|
||||
),
|
||||
// Codex MCP config snippet — for operators whose
|
||||
// external agent is a codex CLI (@openai/codex)
|
||||
// session. Wires the molecule MCP server into
|
||||
// ~/.codex/config.toml. Outbound-tools-only today;
|
||||
// codex's MCP client doesn't route arbitrary
|
||||
// notifications/* so push parity needs a separate
|
||||
// bridge daemon (future work).
|
||||
"codex_snippet": strings.ReplaceAll(
|
||||
strings.ReplaceAll(externalCodexTemplate,
|
||||
"{{PLATFORM_URL}}", platformURL),
|
||||
"{{WORKSPACE_ID}}", id,
|
||||
),
|
||||
// OpenClaw MCP config snippet — for operators whose
|
||||
// external agent is an openclaw session. Wires the
|
||||
// molecule MCP server via `openclaw mcp set` + starts
|
||||
// the gateway on loopback. Outbound-tools-only today;
|
||||
// full push parity needs a sessions.steer bridge
|
||||
// daemon (future work).
|
||||
"openclaw_snippet": strings.ReplaceAll(
|
||||
strings.ReplaceAll(externalOpenClawTemplate,
|
||||
"{{PLATFORM_URL}}", platformURL),
|
||||
"{{WORKSPACE_ID}}", id,
|
||||
),
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user