forked from molecule-ai/molecule-core
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:
commit
b08c632740
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
226
canvas/src/components/ExternalConnectModal.tsx
Normal file
226
canvas/src/components/ExternalConnectModal.tsx
Normal 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'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'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>
|
||||
);
|
||||
}
|
||||
106
workspace-server/internal/handlers/external_connection.go
Normal file
106
workspace-server/internal/handlers/external_connection.go
Normal 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())
|
||||
`
|
||||
149
workspace-server/internal/handlers/runtime_registry.go
Normal file
149
workspace-server/internal/handlers/runtime_registry.go
Normal 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)
|
||||
}
|
||||
111
workspace-server/internal/handlers/runtime_registry_test.go
Normal file
111
workspace-server/internal/handlers/runtime_registry_test.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user