diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index 9819fd52..11b2b405 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -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(null); const [workspaces, setWorkspaces] = useState([]); + // 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(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" /> - + {/* 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. */} + + + {!isExternal && ( + + )}
+ {/* 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. */} + setExternalConnection(null)} + /> ); } diff --git a/canvas/src/components/ExternalConnectModal.tsx b/canvas/src/components/ExternalConnectModal.tsx new file mode 100644 index 00000000..c3de5557 --- /dev/null +++ b/canvas/src/components/ExternalConnectModal.tsx @@ -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("python"); + const [copiedKey, setCopiedKey] = useState(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 "" 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 = ""', + `AUTH_TOKEN = "${info.auth_token}"`, + ); + const filledCurl = info.curl_register_template.replace( + 'WORKSPACE_AUTH_TOKEN=""', + `WORKSPACE_AUTH_TOKEN="${info.auth_token}"`, + ); + + return ( + !o && onClose()}> + + + + + Connect your external agent + + + Paste the snippet below into your agent's deployment. The + auth token is shown only once + {" "}— save it somewhere safe before closing this dialog. + + + {/* Tabs */} +
+ {(["python", "curl", "fields"] as Tab[]).map((t) => ( + + ))} +
+ + {/* Snippet area */} +
+ {tab === "python" && ( + copy(filledPython, "python")} + /> + )} + {tab === "curl" && ( + copy(filledCurl, "curl")} + /> + )} + {tab === "fields" && ( +
+ copy(info.workspace_id, "wsid")} copied={copiedKey === "wsid"} /> + copy(info.platform_url, "url")} copied={copiedKey === "url"} /> + copy(info.auth_token, "tok")} + copied={copiedKey === "tok"} + mono + /> + copy(info.registry_endpoint, "reg")} copied={copiedKey === "reg"} /> + copy(info.heartbeat_endpoint, "hb")} copied={copiedKey === "hb"} /> +
+ )} +
+ +
+ +
+
+
+
+ ); +} + +function SnippetBlock({ + value, + label, + copied, + onCopy, +}: { + value: string; + label: string; + copyKey: string; + copied: boolean; + onCopy: () => void; +}) { + return ( +
+
+ {label} + +
+
+        {value}
+      
+
+ ); +} + +function Field({ + label, + value, + onCopy, + copied, + mono, +}: { + label: string; + value: string; + onCopy: () => void; + copied: boolean; + mono?: boolean; +}) { + return ( +
+ {label} + + {value || "(missing)"} + + +
+ ); +} diff --git a/workspace-server/internal/handlers/external_connection.go b/workspace-server/internal/handlers/external_connection.go new file mode 100644 index 00000000..7a73a37b --- /dev/null +++ b/workspace-server/internal/handlers/external_connection.go @@ -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="" +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 = "" +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()) +` diff --git a/workspace-server/internal/handlers/runtime_registry.go b/workspace-server/internal/handlers/runtime_registry.go new file mode 100644 index 00000000..b5413e15 --- /dev/null +++ b/workspace-server/internal/handlers/runtime_registry.go @@ -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: "-default" is the vanilla variant of + // . 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) +} diff --git a/workspace-server/internal/handlers/runtime_registry_test.go b/workspace-server/internal/handlers/runtime_registry_test.go new file mode 100644 index 00000000..63fa8bdc --- /dev/null +++ b/workspace-server/internal/handlers/runtime_registry_test.go @@ -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 +} diff --git a/workspace-server/internal/handlers/workspace.go b/workspace-server/internal/handlers/workspace.go index 91ece238..f5ef268e 100644 --- a/workspace-server/internal/handlers/workspace.go +++ b/workspace-server/internal/handlers/workspace.go @@ -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" @@ -264,25 +265,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 } diff --git a/workspace-server/internal/handlers/workspace_provision.go b/workspace-server/internal/handlers/workspace_provision.go index 0ebb0503..69c44dd2 100644 --- a/workspace-server/internal/handlers/workspace_provision.go +++ b/workspace-server/internal/handlers/workspace_provision.go @@ -493,15 +493,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