Merge pull request #2064 from Molecule-AI/feat/external-runtime-first-class

feat(external-runtime): first-class BYO-compute workspaces + manifest-driven runtime registry
This commit is contained in:
Hongming Wang 2026-04-26 23:38:34 +00:00 committed by GitHub
commit b08c632740
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 756 additions and 30 deletions

View File

@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback, useId, useMemo } from "react"
import * as Dialog from "@radix-ui/react-dialog";
import { api } from "@/lib/api";
import { isSaaSTenant } from "@/lib/tenant";
import { ExternalConnectModal, type ExternalConnectionInfo } from "./ExternalConnectModal";
interface WorkspaceOption {
id: string;
@ -54,6 +55,13 @@ export function CreateWorkspaceButton() {
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [workspaces, setWorkspaces] = useState<WorkspaceOption[]>([]);
// External-runtime path: skip docker provision, mint a workspace_auth_token,
// and surface the connection snippet in a modal after create. When
// isExternal is true the template / model / hermes-provider fields are
// hidden (they're meaningless for BYO-compute agents).
const [isExternal, setIsExternal] = useState(false);
const [externalConnection, setExternalConnection] =
useState<ExternalConnectionInfo | null>(null);
// Hermes-specific state
const [hermesProvider, setHermesProvider] = useState("anthropic");
@ -185,21 +193,42 @@ export function CreateWorkspaceButton() {
? parseFloat(budgetLimit)
: null;
await api.post("/workspaces", {
const createResp = await api.post<{
id: string;
status: string;
external?: boolean;
connection?: ExternalConnectionInfo;
}>("/workspaces", {
name: name.trim(),
role: role.trim() || undefined,
template: template.trim() || undefined,
// External workspaces don't consume a template — skip it so the
// backend doesn't try to resolve a non-existent dir and log a
// misleading "template not found" warning.
template: isExternal ? undefined : (template.trim() || undefined),
tier,
parent_id: parentId || undefined,
budget_limit: parsedBudget,
canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
...(isHermes && provider
// Runtime=external flips the backend into awaiting-agent mode:
// no container provisioning, token minted, connection payload
// returned in the response for the modal below.
...(isExternal ? { runtime: "external" } : {}),
...(!isExternal && isHermes && provider
? {
secrets: { [provider.envVar]: hermesApiKey.trim() },
model: hermesModel.trim(),
}
: {}),
});
// External path: keep the create dialog open just long enough to
// hand control to the connect modal, then close. The connect
// modal holds the token; we CANNOT re-fetch it later. If the
// backend somehow returns external=true without a connection
// payload we still close the create dialog — the operator will
// have to mint a token via POST /workspaces/:id/tokens.
if (isExternal && createResp.connection) {
setExternalConnection(createResp.connection);
}
setOpen(false);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to create workspace");
@ -265,13 +294,33 @@ export function CreateWorkspaceButton() {
type="number"
helper="Leave blank for unlimited"
/>
<InputField
label="Template"
value={template}
onChange={setTemplate}
placeholder="e.g. seo-agent (from workspace-configs-templates/)"
mono
/>
{/* External toggle when on, this workspace is BYO-compute:
no template, no model, no hermes provider fields. Backend
returns a copyable connection snippet via the modal. */}
<label className="flex items-start gap-2 rounded-lg border border-zinc-800 p-3 cursor-pointer hover:border-zinc-700 transition-colors">
<input
type="checkbox"
checked={isExternal}
onChange={(e) => setIsExternal(e.target.checked)}
className="mt-0.5"
/>
<div className="text-xs">
<div className="text-zinc-200 font-medium">External agent (bring your own compute)</div>
<div className="text-zinc-500 mt-0.5">
Skip the container. We&apos;ll return a workspace_id + auth token + ready-to-paste snippet so an agent running on your laptop / server / CI can register via A2A.
</div>
</div>
</label>
{!isExternal && (
<InputField
label="Template"
value={template}
onChange={setTemplate}
placeholder="e.g. seo-agent (from workspace-configs-templates/)"
mono
/>
)}
<div>
<div
@ -448,6 +497,14 @@ export function CreateWorkspaceButton() {
</div>
</Dialog.Content>
</Dialog.Portal>
{/* Rendered as a sibling so it stays mounted after the create dialog
closes. Without this the auth_token would disappear the moment
the create modal unmounted its React subtree the operator
would never see the copy-paste snippet. */}
<ExternalConnectModal
info={externalConnection}
onClose={() => setExternalConnection(null)}
/>
</Dialog.Root>
);
}

View File

@ -0,0 +1,226 @@
// ExternalConnectModal — shown once after creating a runtime="external"
// workspace. Surfaces the workspace_auth_token + ready-to-paste snippets
// so the operator can hand them to whoever runs their off-host agent
// without piecing together the register payload from docs.
//
// Security posture:
// - The auth_token is visible once. After the modal closes, the value
// is unrecoverable (the /workspaces/:id read endpoints never echo it).
// UI warns the operator before they dismiss.
// - A "copy to clipboard" button uses the navigator.clipboard API which
// is same-origin and requires user gesture — no cross-origin leak.
// - Snippets use placeholders for the operator's own public URL
// ($AGENT_URL). They ARE NOT filled in server-side because the
// server doesn't know where the operator's agent will live.
import { useCallback, useState } from "react";
import * as Dialog from "@radix-ui/react-dialog";
export interface ExternalConnectionInfo {
workspace_id: string;
platform_url: string;
auth_token: string;
registry_endpoint: string;
heartbeat_endpoint: string;
curl_register_template: string;
python_snippet: string;
}
interface Props {
info: ExternalConnectionInfo | null;
onClose: () => void;
}
type Tab = "python" | "curl" | "fields";
export function ExternalConnectModal({ info, onClose }: Props) {
const [tab, setTab] = useState<Tab>("python");
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const copy = useCallback(async (value: string, key: string) => {
try {
await navigator.clipboard.writeText(value);
setCopiedKey(key);
// Auto-clear the "Copied!" label after 1.5s so a second copy
// attempt feels responsive — without the reset, the second
// click appears as a no-op.
window.setTimeout(() => setCopiedKey(null), 1500);
} catch {
// Fallback for browsers that refuse clipboard access (http://
// over insecure origin, Safari private mode, etc.). We surface
// a minimal textarea so the operator can manually copy.
const el = document.getElementById(`fallback-${key}`) as HTMLTextAreaElement | null;
if (el) {
el.select();
}
}
}, []);
if (!info) return null;
// Python snippet is stamped server-side with workspace_id +
// platform_url but leaves AUTH_TOKEN as a "<paste …>" placeholder
// (that's what we're showing in the modal). Fill in the real
// token here so the snippet the operator copies is truly ready-to-run.
const filledPython = info.python_snippet.replace(
'AUTH_TOKEN = "<paste from create response>"',
`AUTH_TOKEN = "${info.auth_token}"`,
);
const filledCurl = info.curl_register_template.replace(
'WORKSPACE_AUTH_TOKEN="<paste from create response>"',
`WORKSPACE_AUTH_TOKEN="${info.auth_token}"`,
);
return (
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/60 z-50" />
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[min(720px,92vw)] -translate-x-1/2 -translate-y-1/2 rounded-xl bg-zinc-900 border border-zinc-700 p-6 shadow-2xl">
<Dialog.Title className="text-lg font-semibold text-white">
Connect your external agent
</Dialog.Title>
<Dialog.Description className="mt-1 text-sm text-zinc-400">
Paste the snippet below into your agent&apos;s deployment. The
auth token is shown <span className="text-amber-400">only once</span>
{" "} save it somewhere safe before closing this dialog.
</Dialog.Description>
{/* Tabs */}
<div
role="tablist"
aria-label="Connection snippet format"
className="mt-4 flex gap-1 border-b border-zinc-800"
>
{(["python", "curl", "fields"] as Tab[]).map((t) => (
<button
key={t}
type="button"
role="tab"
aria-selected={tab === t}
onClick={() => setTab(t)}
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
tab === t
? "border-blue-500 text-white"
: "border-transparent text-zinc-500 hover:text-zinc-300"
}`}
>
{t === "python" ? "Python SDK" : t === "curl" ? "curl" : "Fields"}
</button>
))}
</div>
{/* Snippet area */}
<div className="mt-3">
{tab === "python" && (
<SnippetBlock
value={filledPython}
label="Python (recommended — includes heartbeat loop)"
copyKey="python"
copied={copiedKey === "python"}
onCopy={() => copy(filledPython, "python")}
/>
)}
{tab === "curl" && (
<SnippetBlock
value={filledCurl}
label="curl — one-shot register only (no heartbeat)"
copyKey="curl"
copied={copiedKey === "curl"}
onCopy={() => copy(filledCurl, "curl")}
/>
)}
{tab === "fields" && (
<div className="space-y-2">
<Field label="workspace_id" value={info.workspace_id} onCopy={() => copy(info.workspace_id, "wsid")} copied={copiedKey === "wsid"} />
<Field label="platform_url" value={info.platform_url} onCopy={() => copy(info.platform_url, "url")} copied={copiedKey === "url"} />
<Field
label="auth_token"
value={info.auth_token}
onCopy={() => copy(info.auth_token, "tok")}
copied={copiedKey === "tok"}
mono
/>
<Field label="registry_endpoint" value={info.registry_endpoint} onCopy={() => copy(info.registry_endpoint, "reg")} copied={copiedKey === "reg"} />
<Field label="heartbeat_endpoint" value={info.heartbeat_endpoint} onCopy={() => copy(info.heartbeat_endpoint, "hb")} copied={copiedKey === "hb"} />
</div>
)}
</div>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-200"
>
I&apos;ve saved it close
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
function SnippetBlock({
value,
label,
copied,
onCopy,
}: {
value: string;
label: string;
copyKey: string;
copied: boolean;
onCopy: () => void;
}) {
return (
<div>
<div className="flex items-center justify-between pb-1">
<span className="text-xs text-zinc-500">{label}</span>
<button
type="button"
onClick={onCopy}
className="text-xs px-2 py-1 rounded bg-blue-600/80 hover:bg-blue-500 text-white"
>
{copied ? "Copied!" : "Copy"}
</button>
</div>
<pre className="text-xs bg-zinc-950 border border-zinc-800 rounded-lg p-3 max-h-80 overflow-auto whitespace-pre-wrap break-all font-mono text-zinc-200">
{value}
</pre>
</div>
);
}
function Field({
label,
value,
onCopy,
copied,
mono,
}: {
label: string;
value: string;
onCopy: () => void;
copied: boolean;
mono?: boolean;
}) {
return (
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500 w-36 shrink-0">{label}</span>
<code
className={`flex-1 text-xs bg-zinc-950 border border-zinc-800 rounded px-2 py-1 text-zinc-200 break-all ${mono ? "font-mono" : ""}`}
>
{value || "(missing)"}
</code>
<button
type="button"
onClick={onCopy}
disabled={!value}
className="text-xs px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-200 disabled:opacity-40"
>
{copied ? "Copied!" : "Copy"}
</button>
</div>
);
}

View File

@ -0,0 +1,106 @@
package handlers
// external_connection.go — copy-paste connection payload shown once to
// the operator when they create a runtime="external" workspace.
//
// The canvas UI surfaces these in a single modal so the operator can
// hand the block to whoever runs their external agent without having
// to piece together workspace_id + platform_url + auth_token + API
// shape from the docs. curl snippet has zero dependencies; Python
// snippet pairs with molecule-sdk-python's A2AServer + RemoteAgentClient.
import (
"os"
"github.com/gin-gonic/gin"
)
// externalPlatformURL returns the public URL at which this workspace-
// server instance is reachable by the operator's external agent. This
// is NOT necessarily the caller's Host header (which could be an
// internal CF tunnel hostname). Prefer the EXTERNAL_PLATFORM_URL env
// that Railway/ops sets for the tenant; fall back to the request's
// Host + scheme if unset.
func externalPlatformURL(c *gin.Context) string {
if v := os.Getenv("EXTERNAL_PLATFORM_URL"); v != "" {
return v
}
scheme := "https"
if xf := c.Request.Header.Get("X-Forwarded-Proto"); xf != "" {
scheme = xf
} else if c.Request.TLS == nil {
scheme = "http"
}
host := c.Request.Host
if xh := c.Request.Header.Get("X-Forwarded-Host"); xh != "" {
host = xh
}
return scheme + "://" + host
}
// externalCurlTemplate — zero-dependency register snippet. Placeholders:
// - {{PLATFORM_URL}}, {{WORKSPACE_ID}} — filled server-side
// - $WORKSPACE_AUTH_TOKEN — env var, operator sets
// - $AGENT_URL — env var, operator's public HTTPS endpoint
//
// SSRF filter rejects private IPs at register time, so AGENT_URL must
// resolve to a public host.
//
// Heartbeat loop is NOT included here — curl is fine for one-shot
// register; keeping the workspace alive wants a real loop, so point
// operators at the Python snippet for long-lived setups.
const externalCurlTemplate = `# Replace AGENT_URL with YOUR agent's public HTTPS endpoint, then run:
export WORKSPACE_AUTH_TOKEN="<paste from create response>"
export AGENT_URL="https://your-agent.example.com"
curl -fsS -X POST "{{PLATFORM_URL}}/registry/register" \
-H "Authorization: Bearer $WORKSPACE_AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "{{WORKSPACE_ID}}",
"url": "'"$AGENT_URL"'",
"agent_card": {
"name": "My External Agent",
"description": "",
"version": "0.1.0"
}
}'
`
// externalPythonTemplate uses molecule-sdk-python's RemoteAgentClient +
// A2AServer (PR #13 in that repo). Until the SDK cuts a v0.y release
// to PyPI the snippet pins git+main.
const externalPythonTemplate = `# pip install 'git+https://github.com/Molecule-AI/molecule-sdk-python.git@main'
import asyncio
from molecule_agent import RemoteAgentClient, A2AServer
WORKSPACE_ID = "{{WORKSPACE_ID}}"
PLATFORM_URL = "{{PLATFORM_URL}}"
AUTH_TOKEN = "<paste from create response>"
INBOUND_URL = "https://your-agent.example.com/a2a/inbound" # your public HTTPS endpoint
async def handle(request: dict) -> dict:
# request has parts, message, task_id, idempotency_key
text = "".join(p.get("text", "") for p in request.get("parts", []) if p.get("type") == "text")
return {"parts": [{"type": "text", "text": f"echo: {text}"}]}
async def main():
client = RemoteAgentClient(
workspace_id=WORKSPACE_ID,
platform_url=PLATFORM_URL,
auth_token=AUTH_TOKEN,
)
server = A2AServer(
agent_id=client.workspace_id,
inbound_url=INBOUND_URL,
message_handler=handle,
)
server.start_in_background()
client.reported_url = INBOUND_URL
client.register() # one-shot announcement
await client.run_heartbeat_loop_async() # keeps the workspace online
if __name__ == "__main__":
asyncio.run(main())
`

View File

@ -0,0 +1,149 @@
package handlers
// runtime_registry.go — single source of truth for "which runtime
// strings is the provisioner willing to honor".
//
// Before this file, knownRuntimes was a hardcoded Go map in
// workspace_provision.go, kept in sync MANUALLY with both
// workspace/build-all.sh and manifest.json's workspace_templates.
// That drift produced two visible bugs:
//
// - "gemini-cli" existed in manifest.json but not the Go map, so
// the UI/workspace-create rejected it and fell back to langgraph.
// - "claude-code-default" in manifest vs "claude-code" in Go —
// operators typing the manifest name got silently coerced.
//
// The fix: read manifest.json at boot. manifest.json lives in the
// monorepo root and is already the declarative registry — adding a
// runtime now means one line in that file + cutting the image.
// The Go allowlist is built from it + the hardcoded "external"
// meta-runtime (which has no template repo — it's a first-class
// "bring your own compute" option).
//
// Fallback: if manifest.json isn't readable (dev container without
// the file, tests without the workspace tree mounted) we fall back
// to the pre-refactor hardcoded list so nothing regresses.
import (
"encoding/json"
"log"
"os"
"path/filepath"
"strings"
)
// manifestPath defaults to the repo root next to the binary. In
// production the workspace-server Dockerfile COPY's manifest.json
// into /app/manifest.json. Override with WORKSPACE_MANIFEST_PATH
// when running from an unusual location.
func manifestPath() string {
if v := os.Getenv("WORKSPACE_MANIFEST_PATH"); v != "" {
return v
}
// Standard container layout.
if _, err := os.Stat("/app/manifest.json"); err == nil {
return "/app/manifest.json"
}
// Dev: cwd + ../../manifest.json (run from workspace-server/cmd/server).
for _, p := range []string{"manifest.json", "../manifest.json", "../../manifest.json"} {
if abs, err := filepath.Abs(p); err == nil {
if _, err := os.Stat(abs); err == nil {
return abs
}
}
}
return ""
}
// manifestEntry mirrors the shape of a workspace_templates item.
// Only the fields we read are declared; extras are ignored.
type manifestEntry struct {
Name string `json:"name"`
Repo string `json:"repo"`
}
type manifestFile struct {
WorkspaceTemplates []manifestEntry `json:"workspace_templates"`
}
// fallbackRuntimes is used when manifest.json can't be loaded. Keeps
// tests + dev containers working even if the file isn't mounted.
// Kept slightly broader than the original hardcoded map so a stale
// manifest doesn't silently drop a runtime that was previously
// supported in the wild. "external" is always a valid runtime —
// manifest or not — because it has no template repo.
var fallbackRuntimes = map[string]struct{}{
"langgraph": {},
"claude-code": {},
"openclaw": {},
"crewai": {},
"autogen": {},
"deepagents": {},
"hermes": {},
"codex": {},
"gemini-cli": {},
"external": {},
}
// loadRuntimesFromManifest builds the runtime allowlist from
// manifest.json. Each workspace_templates[].name is normalized to its
// base runtime identifier (strips the `-default` suffix templates
// use for the "vanilla" variant of their runtime) and added to the
// set. "external" is always injected — it's not a template-backed
// runtime, it's the BYO-compute meta-runtime.
//
// Caller logs + falls back to fallbackRuntimes on any error. Not
// returning the fallback here ourselves so the caller can decide
// how loud to be about the miss (prod = WARN, tests = silent).
func loadRuntimesFromManifest(path string) (map[string]struct{}, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m manifestFile
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
out := map[string]struct{}{
// external is ALWAYS available — it has no template repo, so
// the manifest doesn't know about it. Injected here so we
// don't need a special-case in every caller.
"external": {},
}
for _, e := range m.WorkspaceTemplates {
name := strings.TrimSpace(e.Name)
if name == "" {
continue
}
// Normalize template-name → runtime-identifier.
// Convention: "<runtime>-default" is the vanilla variant of
// <runtime>. Strip the suffix so both `claude-code` and
// `claude-code-default` resolve to the same runtime.
name = strings.TrimSuffix(name, "-default")
out[name] = struct{}{}
}
return out, nil
}
// initKnownRuntimes is called from the package init chain (see
// workspace_provision.go var initialization) to replace the
// fallback map with the manifest-derived one. Idempotent —
// safe to call multiple times.
func initKnownRuntimes() {
path := manifestPath()
if path == "" {
log.Printf("runtime registry: manifest.json not found, using fallback allowlist (%d entries)", len(fallbackRuntimes))
return
}
loaded, err := loadRuntimesFromManifest(path)
if err != nil {
log.Printf("runtime registry: manifest.json load failed (%v) — using fallback allowlist", err)
return
}
knownRuntimes = loaded
names := make([]string, 0, len(loaded))
for k := range loaded {
names = append(names, k)
}
log.Printf("runtime registry: loaded %d runtimes from %s: %v", len(loaded), path, names)
}

View File

@ -0,0 +1,111 @@
package handlers
// Unit tests for runtime_registry.go. Verify:
// 1. Happy path — manifest.json maps correctly to runtime names
// (including the -default suffix strip).
// 2. "external" is always injected, even on manifests without it.
// 3. Missing file / malformed JSON returns error, caller uses
// fallback (tested at the initKnownRuntimes level via integration).
import (
"os"
"path/filepath"
"testing"
)
func TestLoadRuntimesFromManifest_StripsDefaultSuffix(t *testing.T) {
// This mirrors the real manifest.json: claude-code-default is the
// "vanilla" variant of claude-code. After load, both names
// collapse to "claude-code".
dir := t.TempDir()
path := filepath.Join(dir, "manifest.json")
err := os.WriteFile(path, []byte(`{
"workspace_templates": [
{"name": "claude-code-default", "repo": "org/t-cc"},
{"name": "langgraph", "repo": "org/t-lg"},
{"name": "hermes", "repo": "org/t-hermes"}
]
}`), 0600)
if err != nil {
t.Fatalf("write: %v", err)
}
got, err := loadRuntimesFromManifest(path)
if err != nil {
t.Fatalf("load: %v", err)
}
want := []string{"claude-code", "langgraph", "hermes", "external"}
for _, w := range want {
if _, ok := got[w]; !ok {
t.Errorf("want runtime %q in set, missing. got=%v", w, keys(got))
}
}
// "claude-code-default" must NOT survive as-is — it should have
// been normalized to "claude-code" above. If both are present
// something's wrong with the TrimSuffix.
if _, ok := got["claude-code-default"]; ok {
t.Errorf("expected '-default' suffix stripped, still present: %v", keys(got))
}
}
func TestLoadRuntimesFromManifest_ExternalAlwaysInjected(t *testing.T) {
// Even a manifest without external (which matches reality —
// external has no template repo) must still produce "external"
// in the set, because it's the BYO-compute meta-runtime.
dir := t.TempDir()
path := filepath.Join(dir, "manifest.json")
_ = os.WriteFile(path, []byte(`{"workspace_templates":[{"name":"langgraph","repo":"org/t"}]}`), 0600)
got, err := loadRuntimesFromManifest(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if _, ok := got["external"]; !ok {
t.Errorf("external must be injected even when absent from manifest: %v", keys(got))
}
}
func TestLoadRuntimesFromManifest_MissingFileErrors(t *testing.T) {
_, err := loadRuntimesFromManifest("/does/not/exist.json")
if err == nil {
t.Fatal("expected error for missing file")
}
}
func TestLoadRuntimesFromManifest_MalformedJSON(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.json")
_ = os.WriteFile(path, []byte("not json"), 0600)
_, err := loadRuntimesFromManifest(path)
if err == nil {
t.Fatal("expected error for malformed JSON")
}
}
// TestRealManifestParses — sanity check against the actual
// monorepo manifest.json so a future schema change to that file
// (e.g. workspace_templates → workspace_runtime_templates) surfaces
// here rather than at prod startup.
func TestRealManifestParses(t *testing.T) {
path := manifestPath()
if path == "" {
t.Skip("manifest.json not discoverable from this test cwd")
}
got, err := loadRuntimesFromManifest(path)
if err != nil {
t.Fatalf("real manifest load: %v", err)
}
// Core runtimes we always expect to ship.
for _, must := range []string{"langgraph", "hermes", "claude-code", "external"} {
if _, ok := got[must]; !ok {
t.Errorf("real manifest missing runtime %q — got=%v", must, keys(got))
}
}
}
func keys(m map[string]struct{}) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}

View File

@ -20,6 +20,7 @@ import (
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"github.com/Molecule-AI/molecule-monorepo/platform/pkg/provisionhook"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@ -278,25 +279,91 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
"runtime": payload.Runtime,
})
// External workspaces: no container provisioning — just set the URL and mark online
if payload.External {
// External workspaces: no container provisioning. Two shapes:
// (a) URL supplied up-front — the operator already has their
// agent running somewhere reachable; we mark it online
// immediately. Legacy flow, preserved for callers that
// don't need the copy-this-snippet UX (org-import, etc.).
// (b) URL omitted — the operator will install
// molecule-sdk-python or another A2A server later. We
// mint a workspace_auth_token now and return it alongside
// workspace_id + platform_url so the canvas UI can show
// one copy-paste connection snippet. Status is set to
// "awaiting_agent" — distinct from "provisioning" (which
// implies docker work in flight) so the canvas can render
// a "waiting for external agent to connect" state without
// tripping the provisioning-timeout UX.
if payload.External || payload.Runtime == "external" {
var connectionToken string
if payload.URL != "" {
db.DB.ExecContext(ctx, `UPDATE workspaces SET url = $1, status = 'online', updated_at = now() WHERE id = $2`, payload.URL, id)
db.DB.ExecContext(ctx, `UPDATE workspaces SET url = $1, status = 'online', runtime = 'external', updated_at = now() WHERE id = $2`, payload.URL, id)
if err := db.CacheURL(ctx, id, payload.URL); err != nil {
log.Printf("External workspace: failed to cache URL for %s: %v", id, err)
}
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_ONLINE", id, map[string]interface{}{
"name": payload.Name, "external": true,
})
} else {
db.DB.ExecContext(ctx, `UPDATE workspaces SET status = 'online', updated_at = now() WHERE id = $1`, id)
// Pre-register flow: mint a token and park the workspace
// in awaiting_agent. First POST /registry/register call
// from the external agent (with this token + its URL)
// flips the row to online.
db.DB.ExecContext(ctx, `UPDATE workspaces SET status = 'awaiting_agent', runtime = 'external', updated_at = now() WHERE id = $1`, id)
tok, tokErr := wsauth.IssueToken(ctx, db.DB, id)
if tokErr != nil {
log.Printf("External workspace %s: token issuance failed: %v", id, tokErr)
// Non-fatal — the workspace row still exists; the
// operator can call POST /workspaces/:id/tokens later
// to mint one. Return a 201 with a hint instead of
// 500'ing a partial-success write.
} else {
connectionToken = tok
}
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_AWAITING_AGENT", id, map[string]interface{}{
"name": payload.Name, "external": true,
})
}
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_ONLINE", id, map[string]interface{}{
"name": payload.Name, "external": true,
})
log.Printf("Created external workspace %s (%s) at %s", payload.Name, id, payload.URL)
c.JSON(http.StatusCreated, gin.H{
log.Printf("Created external workspace %s (%s) url=%q awaiting=%v",
payload.Name, id, payload.URL, payload.URL == "")
resp := gin.H{
"id": id,
"status": "online",
"external": true,
})
}
if payload.URL != "" {
resp["status"] = "online"
} else {
resp["status"] = "awaiting_agent"
// Connection snippet payload. Returned ONCE on create —
// the token is not recoverable from any later read. UI
// is responsible for surfacing this in a copy-paste modal.
platformURL := strings.TrimSuffix(externalPlatformURL(c), "/")
resp["connection"] = gin.H{
"workspace_id": id,
"platform_url": platformURL,
"auth_token": connectionToken, // may be "" if IssueToken failed above
"registry_endpoint": platformURL + "/registry/register",
"heartbeat_endpoint": platformURL + "/registry/heartbeat",
// Pre-formatted snippet that a non-Go operator can
// paste verbatim. curl-based so there's no SDK
// install dependency. The external agent only
// needs to replace $AGENT_URL with its own public URL.
"curl_register_template": strings.ReplaceAll(
strings.ReplaceAll(externalCurlTemplate,
"{{PLATFORM_URL}}", platformURL),
"{{WORKSPACE_ID}}", id,
),
// Python/SDK snippet. molecule-sdk-python PR #13
// shipped A2AServer + RemoteAgentClient specifically
// for this flow. The SDK is not yet on PyPI — the
// snippet pins @main until we cut a release.
"python_snippet": strings.ReplaceAll(
strings.ReplaceAll(externalPythonTemplate,
"{{PLATFORM_URL}}", platformURL),
"{{WORKSPACE_ID}}", id,
),
}
}
c.JSON(http.StatusCreated, resp)
return
}

View File

@ -514,15 +514,25 @@ func configDirName(workspaceID string) string {
//
// Keep in sync with workspace/build-all.sh — adding a new
// runtime means bumping both this list and the Docker image tags.
var knownRuntimes = map[string]struct{}{
"langgraph": {},
"claude-code": {},
"openclaw": {},
"crewai": {},
"autogen": {},
"deepagents": {},
"hermes": {},
"codex": {},
// knownRuntimes is populated from manifest.json at service init (see
// runtime_registry.go). The package init order is:
// 1. var knownRuntimes = fallbackRuntimes
// 2. init() calls initKnownRuntimes() which replaces it if
// manifest.json is readable.
// The fallback matters for unit tests that don't mount the manifest.
//
// "external" is a first-class runtime that intentionally does NOT
// spawn a Docker container. Workspaces with runtime="external" are
// created in status=awaiting_agent; the operator installs
// molecule-sdk-python (or any A2A-compatible agent) somewhere they
// control and calls POST /registry/register with the workspace_id +
// workspace_auth_token from the create response. Canvas proxies A2A
// calls to the registered URL thereafter. "external" has no template
// repo, so it's always injected by the registry layer.
var knownRuntimes = fallbackRuntimes
func init() {
initKnownRuntimes()
}
// yamlQuote emits a YAML double-quoted scalar that safely contains any