From 98a0ba2800a8ce1affe628f9f70894c1c7be19ac Mon Sep 17 00:00:00 2001 From: "hongming-kimi-laptop (Molecule AI agent)" Date: Tue, 12 May 2026 15:14:36 -0700 Subject: [PATCH] fix(runtime): kimi as first-class BYO-compute runtime (SOP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the same pattern as 'external' — no template repo, injected into the runtime allowlist as a meta-runtime. Changes: Backend: - workspace.go: use isExternalLikeRuntime() instead of hardcoded 'external' check so runtime=kimi/kimi-cli workspaces take the BYO-compute path - Preserve the caller's runtime label (kimi/kimi-cli/external) in DB so the canvas shows the correct runtime name Frontend: - Add canvas/src/lib/externalRuntimes.ts utility (mirrors backend isExternalLikeRuntime) — single source of truth for BYO-compute detection - Update all hardcoded 'runtime === external' checks to use the utility: FilesTab, TerminalTab, ConfigTab, WorkspaceNode, mobile/components - Add 'kimi' and 'kimi-cli' to RUNTIME_NAMES display map - CreateWorkspaceDialog: external-runtime selector dropdown so operators can pick Generic External / Kimi CLI / Kimi CLI (alt) Tests: - Go tests pass (registry, restart, plugin install, workspace create) --- .../src/components/CreateWorkspaceDialog.tsx | 21 +++++++++- canvas/src/components/WorkspaceNode.tsx | 3 +- canvas/src/components/mobile/components.tsx | 3 +- canvas/src/components/tabs/ConfigTab.tsx | 5 ++- canvas/src/components/tabs/FilesTab.tsx | 6 ++- canvas/src/components/tabs/TerminalTab.tsx | 5 ++- canvas/src/lib/externalRuntimes.ts | 21 ++++++++++ canvas/src/lib/runtime-names.ts | 2 + .../internal/handlers/workspace.go | 18 ++++++-- .../internal/handlers/workspace_test.go | 42 +++++++++++++++++++ 10 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 canvas/src/lib/externalRuntimes.ts diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index 3830124b..5c446b7e 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -80,6 +80,7 @@ export function CreateWorkspaceButton() { // isExternal is true the template / model / hermes-provider fields are // hidden (they're meaningless for BYO-compute agents). const [isExternal, setIsExternal] = useState(false); + const [externalRuntime, setExternalRuntime] = useState("external"); const [externalConnection, setExternalConnection] = useState(null); @@ -223,6 +224,7 @@ export function CreateWorkspaceButton() { setBudgetLimit(""); setError(null); setHermesProvider("anthropic"); + setExternalRuntime("external"); setHermesApiKey(""); setHermesModel(""); api @@ -282,7 +284,7 @@ export function CreateWorkspaceButton() { // 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 ? { runtime: externalRuntime } : {}), ...(!isExternal && isHermes && provider ? { secrets: { [provider.envVar]: hermesApiKey.trim() }, @@ -382,6 +384,23 @@ export function CreateWorkspaceButton() { + {isExternal && ( +
+ + +
+ )} + {!isExternal && ( >) if (!runtime) return null; return (
- {runtime === "external" ? ( + {isExternalLikeRuntime(runtime) ? ( . @@ -37,7 +38,7 @@ export interface MobileAgent { export function toMobileAgent(node: Node): MobileAgent { const cap = summarizeWorkspaceCapabilities(node.data); const runtime = cap.runtime ?? "unknown"; - const remote = runtime === "external"; + const remote = isExternalLikeRuntime(runtime); return { id: node.id, name: node.data.name || node.id, diff --git a/canvas/src/components/tabs/ConfigTab.tsx b/canvas/src/components/tabs/ConfigTab.tsx index 50ae227b..0c8b5bc3 100644 --- a/canvas/src/components/tabs/ConfigTab.tsx +++ b/canvas/src/components/tabs/ConfigTab.tsx @@ -13,6 +13,7 @@ import { findProviderForModel, type SelectorValue, } from "../ProviderModelSelector"; +import { isExternalLikeRuntime } from "@/lib/externalRuntimes"; interface Props { workspaceId: string; @@ -175,7 +176,7 @@ function deriveProvidersFromModels(models: ModelSpec[]): string[] { // exactly the point of the platform adaptor. The deep `~/.hermes/ // config.yaml` on the container is a separate runtime-internal file, // not this one. -const RUNTIMES_WITH_OWN_CONFIG = new Set(["external"]); +const RUNTIMES_WITH_OWN_CONFIG = new Set(["external", "kimi", "kimi-cli"]); const FALLBACK_RUNTIME_OPTIONS: RuntimeOption[] = [ { value: "", label: "LangGraph (default)", models: [], providers: [] }, @@ -1003,7 +1004,7 @@ export function ConfigTab({ workspaceId }: Props) { : "This runtime manages its own config outside the platform template."}
)} - {!error && config.runtime === "external" && ( + {!error && isExternalLikeRuntime(config.runtime) && ( )} {success && ( diff --git a/canvas/src/components/tabs/FilesTab.tsx b/canvas/src/components/tabs/FilesTab.tsx index 4bf24d32..e97d906f 100644 --- a/canvas/src/components/tabs/FilesTab.tsx +++ b/canvas/src/components/tabs/FilesTab.tsx @@ -9,6 +9,7 @@ import { FileEditor } from "./FilesTab/FileEditor"; import { NotAvailablePanel } from "./FilesTab/NotAvailablePanel"; import { useFilesApi } from "./FilesTab/useFilesApi"; import { buildTree } from "./FilesTab/tree"; +import { isExternalLikeRuntime } from "@/lib/externalRuntimes"; // Re-exports preserved for external imports (e.g. tests importing from `../tabs/FilesTab`) export { buildTree } from "./FilesTab/tree"; @@ -32,7 +33,8 @@ interface Props { * has no platform-owned filesystem. Otherwise the user loses access to * a real surface (e.g. claude-code SaaS workspaces have files served * by ListFiles via EIC; they belong on the rendering path, not here). */ -const RUNTIMES_WITHOUT_FILES = new Set(["external"]); + +const RUNTIMES_WITHOUT_FILES = new Set(["external", "kimi", "kimi-cli"]); export function FilesTab({ workspaceId, data }: Props) { // Early-return for runtimes whose filesystem is not platform-owned. @@ -43,7 +45,7 @@ export function FilesTab({ workspaceId, data }: Props) { // "0 files / No config files yet" reads as a bug. The placeholder // makes the absence intentional and points the user at the right // surface (Chat). - if (data && RUNTIMES_WITHOUT_FILES.has(data.runtime)) { + if (data && isExternalLikeRuntime(data.runtime)) { return ; } return ; diff --git a/canvas/src/components/tabs/TerminalTab.tsx b/canvas/src/components/tabs/TerminalTab.tsx index 0e5ddbe4..3f18609e 100644 --- a/canvas/src/components/tabs/TerminalTab.tsx +++ b/canvas/src/components/tabs/TerminalTab.tsx @@ -13,6 +13,7 @@ interface Props { } import { deriveWsBaseUrl } from "@/lib/ws-url"; +import { isExternalLikeRuntime } from "@/lib/externalRuntimes"; const WS_URL = deriveWsBaseUrl(); @@ -87,7 +88,7 @@ function NotAvailablePanel({ runtime }: { runtime: string }) { /** Runtimes that don't expose a TTY. Keep narrow — only add a runtime * here when its provisioner genuinely has no shell endpoint, otherwise * the user loses access to a real debugging surface. */ -const RUNTIMES_WITHOUT_TERMINAL = new Set(["external"]); +const RUNTIMES_WITHOUT_TERMINAL = new Set(["external", "kimi", "kimi-cli"]); export function TerminalTab({ workspaceId, data }: Props) { // Early-return for runtimes that have no shell. Skips the entire @@ -96,7 +97,7 @@ export function TerminalTab({ workspaceId, data }: Props) { // workspace-server (no /ws/terminal/ route registered for it), // and shows "Connection failed" with a Reconnect button — confusing // because the workspace IS healthy, just doesn't have a TTY. - if (data && RUNTIMES_WITHOUT_TERMINAL.has(data.runtime)) { + if (data && isExternalLikeRuntime(data.runtime)) { return ; } diff --git a/canvas/src/lib/externalRuntimes.ts b/canvas/src/lib/externalRuntimes.ts new file mode 100644 index 00000000..c84783c2 --- /dev/null +++ b/canvas/src/lib/externalRuntimes.ts @@ -0,0 +1,21 @@ +/** + * External-like (BYO-compute) runtime detection. + * + * Mirrors the backend's isExternalLikeRuntime() in + * workspace-server/internal/handlers/runtime_registry.go. + * + * These runtimes have no platform-owned container — the operator installs + * the agent CLI locally and calls /registry/register. They share UX + * behaviour: no Files tab, no Terminal tab, no Docker config, and the + * connection modal shows copy-paste snippets. + */ + +const EXTERNAL_LIKE_RUNTIMES = new Set([ + "external", + "kimi", + "kimi-cli", +]); + +export function isExternalLikeRuntime(runtime: string | undefined): boolean { + return !!runtime && EXTERNAL_LIKE_RUNTIMES.has(runtime); +} diff --git a/canvas/src/lib/runtime-names.ts b/canvas/src/lib/runtime-names.ts index fcc1ef47..f01e9b11 100644 --- a/canvas/src/lib/runtime-names.ts +++ b/canvas/src/lib/runtime-names.ts @@ -9,6 +9,8 @@ const RUNTIME_NAMES: Record = { openclaw: "OpenClaw", crewai: "CrewAI", autogen: "AutoGen", + kimi: "Kimi", + "kimi-cli": "Kimi CLI", }; export function runtimeDisplayName(runtime: string): string { diff --git a/workspace-server/internal/handlers/workspace.go b/workspace-server/internal/handlers/workspace.go index bfccb092..51e31656 100644 --- a/workspace-server/internal/handlers/workspace.go +++ b/workspace-server/internal/handlers/workspace.go @@ -428,13 +428,20 @@ func (h *WorkspaceHandler) Create(c *gin.Context) { // 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" { + if payload.External || isExternalLikeRuntime(payload.Runtime) { var connectionToken string if payload.URL != "" { // URL already validated by validateAgentURL above (before BeginTx). // Now persist it: the external URL is set after the workspace row // commits so that a failed URL UPDATE doesn't roll back the row. - db.DB.ExecContext(ctx, `UPDATE workspaces SET url = $1, status = $2, runtime = 'external', updated_at = now() WHERE id = $3`, payload.URL, models.StatusOnline, id) + // Preserve BYO-compute runtime label (kimi, kimi-cli, external) — + // don't coerce to generic "external" so the canvas can show the + // correct runtime name in the node card. + rt := payload.Runtime + if rt == "" { + rt = "external" + } + db.DB.ExecContext(ctx, `UPDATE workspaces SET url = $1, status = $2, runtime = $3, updated_at = now() WHERE id = $4`, payload.URL, models.StatusOnline, rt, id) if err := db.CacheURL(ctx, id, payload.URL); err != nil { log.Printf("External workspace: failed to cache URL for %s: %v", id, err) } @@ -446,7 +453,12 @@ func (h *WorkspaceHandler) Create(c *gin.Context) { // 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 = $1, runtime = 'external', updated_at = now() WHERE id = $2`, models.StatusAwaitingAgent, id) + // Preserve BYO-compute runtime label (kimi, kimi-cli, external). + rt := payload.Runtime + if rt == "" { + rt = "external" + } + db.DB.ExecContext(ctx, `UPDATE workspaces SET status = $1, runtime = $2, updated_at = now() WHERE id = $3`, models.StatusAwaitingAgent, rt, id) tok, tokErr := wsauth.IssueToken(ctx, db.DB, id) if tokErr != nil { log.Printf("External workspace %s: token issuance failed: %v", id, tokErr) diff --git a/workspace-server/internal/handlers/workspace_test.go b/workspace-server/internal/handlers/workspace_test.go index 4e58a7bf..9d5b1a77 100644 --- a/workspace-server/internal/handlers/workspace_test.go +++ b/workspace-server/internal/handlers/workspace_test.go @@ -559,6 +559,48 @@ func TestWorkspaceCreate_ExternalURL_SSRFSafe(t *testing.T) { } } +// TestWorkspaceCreate_KimiRuntime_PreservesLabel asserts that a workspace +// created with runtime="kimi" takes the BYO-compute path (awaiting_agent, +// no Docker provisioning) and preserves the "kimi" label in the DB instead +// of coercing to "external". Regression guard for SOP runtime addition. +func TestWorkspaceCreate_KimiRuntime_PreservesLabel(t *testing.T) { + t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted") + t.Setenv("MOLECULE_ORG_ID", "") + mock := setupTestDB(t) + setupTestRedis(t) + broadcaster := newTestBroadcaster() + handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir()) + + mock.ExpectBegin() + mock.ExpectExec("INSERT INTO workspaces"). + WithArgs(sqlmock.AnyArg(), "Kimi Agent", nil, 3, "kimi", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + // Pre-register flow: awaiting_agent + runtime preserved as "kimi" + mock.ExpectExec("UPDATE workspaces SET status"). + WithArgs(models.StatusAwaitingAgent, "kimi", sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(0, 1)) + // Token issuance (workspace_auth_tokens, not workspace_tokens) + mock.ExpectExec("INSERT INTO workspace_auth_tokens"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + body := `{"name":"Kimi Agent","runtime":"kimi","tier":3,"canvas":{"x":100,"y":100}}` + c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.Create(c) + + if w.Code != http.StatusCreated { + t.Errorf("expected status 201, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + // TestWorkspaceCreate_ExternalURL_SSRFMetadataBlocked asserts that an external // workspace created with a cloud-metadata URL is rejected with 400 before any // DB write. 169.254.0.0/16 is always blocked regardless of mode (SaaS or