Compare commits

..

4 Commits

Author SHA1 Message Date
core-devops 8e64f9f107 feat(workspaces): fold apply-role-config + read-back-assert into provision_workspace
CI / test (pull_request) Successful in 1m7s
Extends the fail-closed provision_workspace tool with an optional
role_config { model, config_yaml } block so "create + apply-role-config
+ read-back-assert" is ONE fail-closed operation instead of two
separate, skippable steps.

Motivation (#218 prod-team defect): the 5 prod-team workspaces were
provisioned with the correct runtime but template-default role config
(generic name, Sonnet instead of the role's model, empty charter)
because per-role config was never applied as part of provisioning.

Mechanism (source-verified against molecule-core workspace-server):
- model      -> PUT /workspaces/:id/model (writes MODEL_PROVIDER
  workspace_secret; authoritative over config.yaml runtime_config.model
  per the claude-code adapter resolution order; auto-restarts). The
  effective model is read back via GET /workspaces/:id/model and
  ASSERTED == requested; a write-ack is never trusted as success.
- config.yaml -> PUT /workspaces/:id/files/config.yaml (name,
  description/charter, runtime_config.model, required_env; written via
  EIC to the workspace EC2 + auto-restarts). NOT read-back-asserted
  due to the documented PUT/GET path asymmetry (molecule-core
  tests/e2e/test_staging_full_saas.sh) — the model read-back is the
  authoritative effective-config gate.

Fail-closed surface: ROLE_CONFIG_FAILED (write error, with phase),
ROLE_CONFIG_MODEL_MISMATCH (effective model != requested after
read-back). role_config_applied is always present in the result so a
caller cannot mistake a runtime-only provision for a fully-configured
role.

Tests: +3 (success path, model-mismatch fail-closed, role_config
absent). Full suite green: 136 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 02:10:12 -07:00
core-devops 549c15c594 feat(workspaces): add fail-closed provision_workspace MCP tool
CI / test (pull_request) Successful in 57s
Adds a `provision_workspace` MCP tool so an agent can provision a
workspace with a GUARANTEED runtime (claude-code/codex/hermes/openclaw/
langgraph/autogen/crewai/deepagents) via the correct PRODUCT create path
(POST /workspaces with template+runtime) — not the CP-direct
/cp/workspaces/provision path the orchestrator was forced to use.

Enforces the same fail-closed contract as molecule-controlplane#188 on
the agent-facing surface:
  1. Validate runtime against the supported set BEFORE any side effect.
  2. Create via the product path (template drives config/image).
  3. Read the workspace back and assert resolved runtime == requested;
     return a structured RUNTIME_MISMATCH/PROVISION_UNVERIFIED error
     (NOT a success) if the platform silently fell back to langgraph.

This makes the agent surface honest now; it does NOT replace the
required platform-side hard-gate (controlplane#188 + its workspace-
server sibling — each adapter stays runtime-specific, the platform is
the unified SSOT that must error+notify, never silent-advisory).

Refs: molecule-controlplane#188, #184 (CP-direct vs product-create
fidelity gap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 01:19:48 -07:00
hongming-pc2 e267875fb3 Merge PR #18 via Gitea merge queue
CI / test (push) Successful in 36s
Serialized merge by gitea-merge-queue after current-main, SOP, and required CI checks were green.
2026-05-17 19:13:13 +00:00
sdk-dev 68e0505d9f docs(mcp): fix stale SDK reference in Platform Integration section
[Do] Manual gate post
sop-checklist / all-items-acked Manual gate post
CI / test (pull_request) Successful in 19s
PR #17 updated known-issues.md but dropped the CLAUDE.md fix.
The Platform Integration section incorrectly claimed the server uses
the Python SDK (molecule-sdk-python). The MCP server has its own
TypeScript client in src/api.ts — the Python SDK is for remote agents.

Also fixed "reads data via the platform SDK" → "reads data via the
platform REST API" in the Postgres section.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 00:14:04 +00:00
7 changed files with 528 additions and 269 deletions
+5 -2
View File
@@ -96,7 +96,10 @@ The workflow:
### APIs Connected
The server connects to the Molecule AI platform REST API. See the platform SDK (`../molecule-sdk-python`) for the underlying API client used.
The server connects to the Molecule AI platform REST API via its own TypeScript
client (`src/api.ts`). It does not use the Python SDK (`molecule-sdk-python`) —
the Python SDK is for remote agents that run outside the platform; this server
runs as an MCP bridge *on* the operator side.
### Environment Variables
@@ -110,7 +113,7 @@ For local development, copy `.env.example` → `.env` and fill in values.
### Postgres
Platform data lives in Postgres (source of truth). The server reads data via the platform SDK — it does not connect to Postgres directly.
Platform data lives in Postgres (source of truth). The server reads data via the platform REST API — it does not connect to Postgres directly.
## TypeScript Conventions
+183 -2
View File
@@ -26,6 +26,7 @@ import {
PLATFORM_URL,
handleListWorkspaces,
handleCreateWorkspace,
handleProvisionWorkspace,
handleGetWorkspace,
handleDeleteWorkspace,
handleRestartWorkspace,
@@ -119,6 +120,186 @@ function expectJsonContent(result: { content: Array<{ type: string; text: string
expect(parsed).toEqual(expected);
}
/**
* Build a fetch mock that returns a different JSON body on each
* successive call (call 1 -> responses[0], call 2 -> responses[1], ...).
* Used by provision_workspace tests where the handler does a POST
* (create) followed by a GET (read-back) and the two responses differ.
*/
function mockFetchSequence(responses: Array<{ payload: unknown; ok?: boolean; status?: number }>) {
const fn = jest.fn();
for (const r of responses) {
fn.mockResolvedValueOnce({
ok: r.ok ?? true,
status: r.status ?? 200,
text: jest.fn().mockResolvedValue(JSON.stringify(r.payload)),
});
}
return fn;
}
// ============================================================
// provision_workspace (fail-closed) tests
// ============================================================
describe("handleProvisionWorkspace (fail-closed contract)", () => {
beforeEach(() => {
jest.resetAllMocks();
});
test("rejects an unsupported runtime BEFORE any platform call", async () => {
const fetchMock = jest.fn();
global.fetch = fetchMock;
const result = await handleProvisionWorkspace({
name: "bad",
runtime: "gpt-5.5-turbo",
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error).toBe("UNSUPPORTED_RUNTIME");
expect(parsed.provisioned).toBe(false);
// No side effect — fail-closed must not have touched the platform.
expect(fetchMock).not.toHaveBeenCalled();
});
test("returns RUNTIME_MISMATCH when platform silently falls back (the #184 footgun)", async () => {
// create returns id; read-back shows langgraph instead of codex.
global.fetch = mockFetchSequence([
{ payload: { id: "ws-9", status: "provisioning" } },
{ payload: { id: "ws-9", runtime: "langgraph" } },
]);
const result = await handleProvisionWorkspace({
name: "codex-dev",
runtime: "codex",
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error).toBe("RUNTIME_MISMATCH");
expect(parsed.provisioned).toBe(false);
expect(parsed.requested_runtime).toBe("codex");
expect(parsed.resolved_runtime).toBe("langgraph");
expect(parsed.workspace_id).toBe("ws-9");
});
test("returns ok=true only when resolved runtime matches the request", async () => {
global.fetch = mockFetchSequence([
{ payload: { id: "ws-7", status: "provisioning" } },
{ payload: { id: "ws-7", runtime: "claude-code" } },
]);
const result = await handleProvisionWorkspace({
name: "cc-dev",
runtime: "claude-code",
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.ok).toBe(true);
expect(parsed.provisioned).toBe(true);
expect(parsed.requested_runtime).toBe("claude-code");
expect(parsed.resolved_runtime).toBe("claude-code");
});
test("returns PROVISION_UNVERIFIED when the runtime cannot be read back", async () => {
global.fetch = mockFetchSequence([
{ payload: { id: "ws-3", status: "provisioning" } },
{ payload: { id: "ws-3" } }, // no runtime field echoed
]);
const result = await handleProvisionWorkspace({
name: "hermes-dev",
runtime: "hermes",
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error).toBe("PROVISION_UNVERIFIED");
expect(parsed.provisioned).toBe(false);
});
test("BYO runtime (external) is not failed on a normalized runtime label", async () => {
global.fetch = mockFetchSequence([
{ payload: { id: "ws-x", status: "awaiting_agent" } },
{ payload: { id: "ws-x", runtime: "external" } },
]);
const result = await handleProvisionWorkspace({
name: "byo",
runtime: "external",
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.ok).toBe(true);
expect(parsed.provisioned).toBe(true);
});
// Call-indexed fetch mock. provision_workspace with role_config makes
// up to 5 sequential calls (POST create, GET runtime, PUT config.yaml,
// PUT model, GET model); a per-call implementation is the robust mock
// for a multi-call handler (mockResolvedValueOnce chains are brittle
// across reset ordering once the call count exceeds ~2).
function mockFetchCalls(seq: unknown[]) {
let i = 0;
return jest.fn().mockImplementation(() => {
const payload = seq[Math.min(i, seq.length - 1)];
i += 1;
return Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify(payload)),
});
});
}
test("role_config: applies config.yaml + model and read-back-asserts the effective model", async () => {
// POST create → GET runtime → PUT config.yaml → PUT model → GET model
global.fetch = mockFetchCalls([
{ id: "ws-pm", status: "provisioning" },
{ id: "ws-pm", runtime: "claude-code" },
{ status: "saved", path: "config.yaml" },
{ status: "saved", model: "opus" },
{ model: "opus", source: "workspace_secrets" },
]) as unknown as typeof fetch;
const result = await handleProvisionWorkspace({
name: "prod-PM",
runtime: "claude-code",
role_config: { model: "opus", config_yaml: "name: prod-PM\nruntime: claude-code\n" },
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.ok).toBe(true);
expect(parsed.role_config_applied).toBe(true);
expect(parsed.applied.model).toBe("opus");
expect(parsed.applied.config_yaml).toBe("written");
});
test("role_config: fails closed when the effective model does not match the requested model", async () => {
// model write acks, but read-back still shows the template default.
global.fetch = mockFetchCalls([
{ id: "ws-bad", status: "provisioning" },
{ id: "ws-bad", runtime: "claude-code" },
{ status: "saved", path: "config.yaml" },
{ status: "saved", model: "opus" },
{ model: "sonnet", source: "workspace_secrets" },
]) as unknown as typeof fetch;
const result = await handleProvisionWorkspace({
name: "prod-PM",
runtime: "claude-code",
role_config: { model: "opus", config_yaml: "name: prod-PM\n" },
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error).toBe("ROLE_CONFIG_MODEL_MISMATCH");
expect(parsed.role_config_applied).toBe(false);
expect(parsed.requested_model).toBe("opus");
expect(parsed.effective_model).toBe("sonnet");
// The workspace still exists (runtime was honored) — surface that.
expect(parsed.provisioned).toBe(true);
});
test("role_config absent → role_config_applied:false, runtime still verified", async () => {
global.fetch = mockFetchCalls([
{ id: "ws-n", status: "provisioning" },
{ id: "ws-n", runtime: "codex" },
]) as unknown as typeof fetch;
const result = await handleProvisionWorkspace({
name: "plain",
runtime: "codex",
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.ok).toBe(true);
expect(parsed.role_config_applied).toBe(false);
});
});
// ============================================================
// apiCall() tests
// ============================================================
@@ -859,12 +1040,12 @@ describe("createServer()", () => {
// and each tool() call is recorded by the mocked McpServer above. If a
// future PR adds a tool file but forgets to call its registerXxxTools
// from createServer(), this count drops and the test fails. We assert
// the concrete current tool count (87) rather than a lower bound so a
// the concrete current tool count (88) rather than a lower bound so a
// silently-dropped handler is also caught.
test("registers all tools (count is stable across registerXxxTools wiring)", () => {
const server = createServer() as unknown as { registeredToolNames: string[] };
const names = server.registeredToolNames;
expect(names.length).toBe(87);
expect(names.length).toBe(88);
// Names must be unique — a duplicate registration would indicate a
// copy-paste mistake in one of the registerXxxTools() calls.
expect(new Set(names).size).toBe(names.length);
+2 -1
View File
@@ -38,6 +38,7 @@ export {
registerWorkspaceTools,
handleListWorkspaces,
handleCreateWorkspace,
handleProvisionWorkspace,
handleGetWorkspace,
handleDeleteWorkspace,
handleRestartWorkspace,
@@ -212,7 +213,7 @@ async function main() {
const server = createServer();
const transport = new StdioServerTransport();
await server.connect(transport);
logInfo("Molecule AI MCP server running on stdio (87 tools available)", { transport: "stdio", toolCount: 87 });
logInfo("Molecule AI MCP server running on stdio (88 tools available)", { transport: "stdio", toolCount: 88 });
}
// Only auto-start when run directly (not when imported for testing).
+2 -3
View File
@@ -90,14 +90,13 @@ export async function handleGetRemoteAgentSetupCommand(params: {
`WORKSPACE_ID=${w.id} \\`,
`PLATFORM_URL=${targetUrl} \\`,
`python3 -c "from molecule_agent import RemoteAgentClient; \\`,
` c = RemoteAgentClient(workspace_id='${w.id}', platform_url='${targetUrl}'); \\`,
` if c.load_token() is None: c.register(); \\`,
` c = RemoteAgentClient.register_from_env(); \\`,
` c.pull_secrets(); \\`,
` c.run_heartbeat_loop()"`,
``,
`# For a richer demo (logging, graceful shutdown) see`,
`# examples/remote-agent/run.py in the molecule-sdk-python checkout.`,
`# The agent will register (mint + cache bearer token at`,
`# The agent will register, mint its bearer token (cached at`,
`# ~/.molecule/${w.id}/.auth_token), pull secrets, then heartbeat.`,
].join("\n");
return toMcpResult({
+336 -1
View File
@@ -1,6 +1,43 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { apiCall, platformGet, toMcpResult } from "../api.js";
import { apiCall, platformGet, toMcpResult, isApiError } from "../api.js";
// Supported runtimes the platform provisioner will honor. Mirrors the
// workspace-server allowlist (`internal/handlers/runtime_registry.go`
// fallbackRuntimes + the template-derived set). This is the *client-side*
// fail-closed guard for the provision_workspace tool: the orchestrator
// gets a clear INVALID_ARGUMENTS instead of the platform silently
// coercing an unknown/empty runtime to langgraph (the #184 / control-
// plane #188 footgun). It is intentionally NOT the authoritative list —
// the platform must still hard-gate (controlplane#188) — but it stops
// the most common caller mistake (typo / omitted runtime) at the door.
export const SUPPORTED_RUNTIMES = [
"claude-code",
"codex",
"hermes",
"openclaw",
"langgraph",
"autogen",
"crewai",
"deepagents",
"kimi",
"kimi-cli",
"external",
] as const;
// Canonical default template per runtime. The product "New Workspace"
// dialog sends a `template` (e.g. "claude-code-default"); the workspace-
// server derives the runtime from the template's config.yaml. Sending
// BOTH (template + runtime) is the most robust call: template drives the
// correct config/image, runtime is the assertion target for the
// request==delivered echo-back check below.
function defaultTemplateFor(runtime: string): string {
// BYO-compute meta-runtimes have no template repo.
if (runtime === "external" || runtime === "kimi" || runtime === "kimi-cli") {
return "";
}
return `${runtime}-default`;
}
export async function handleListWorkspaces() {
const data = await platformGet("/workspaces");
@@ -32,6 +69,261 @@ export async function handleCreateWorkspace(params: {
return toMcpResult(data);
}
/**
* provision_workspace — agent-facing, fail-closed workspace provisioning.
*
* Why this exists (separate from create_workspace): the orchestrator needs
* to bring up the production agent team with a SPECIFIC runtime
* (claude-code / codex / hermes / openclaw / ...). Both the CP-direct
* path AND the raw create path can return success while silently
* delivering a langgraph workspace when the runtime can't be resolved
* (#184 / molecule-controlplane#188). A "201 but wrong runtime" is a
* contract violation, not a degraded success.
*
* This tool enforces the same fail-closed contract on the client side:
* 1. Validate `runtime` against SUPPORTED_RUNTIMES — reject unknown
* BEFORE any platform call (the SDK schema enum also enforces this;
* this is defense-in-depth + a clearer error).
* 2. Call the correct PRODUCT create path (POST /workspaces with both
* `template` and `runtime`), NOT the CP-direct
* /cp/workspaces/provision path the orchestrator had been forced to
* use. Template drives the correct config/image; runtime is the
* assertion target.
* 3. Read the created workspace back and assert resolved runtime ==
* requested runtime. On mismatch (or no runtime echoed) return a
* structured FAILED-CLOSED error with the resolved value so the
* caller can NOT mistake a langgraph fallback for success.
*
* The platform-side hard-gate is still required (controlplane#188 +
* its workspace-server sibling) — this tool does not substitute for it,
* it makes the agent-facing surface honest in the meantime.
*/
export async function handleProvisionWorkspace(params: {
name: string;
runtime: string;
template?: string;
tier?: number;
role?: string;
parent_id?: string;
workspace_dir?: string;
workspace_access?: "none" | "read_only" | "read_write";
role_config?: {
model?: string;
config_yaml?: string;
};
}) {
const { name, runtime, tier, role, parent_id, workspace_dir, workspace_access } = params;
// (1) Fail-closed runtime validation BEFORE any side effect.
if (!(SUPPORTED_RUNTIMES as readonly string[]).includes(runtime)) {
return toMcpResult({
error: "UNSUPPORTED_RUNTIME",
detail: `runtime "${runtime}" is not supported; supported: ${SUPPORTED_RUNTIMES.join(", ")}`,
requested_runtime: runtime,
provisioned: false,
});
}
// (2) Resolve template. Caller may override; default is the canonical
// "<runtime>-default" template the product UI uses. Sending both
// template + runtime is the most robust call (template → correct
// config/image, runtime → assertion target).
const template = params.template ?? defaultTemplateFor(runtime);
const created = await apiCall("POST", "/workspaces", {
name,
role,
template: template || undefined,
tier,
parent_id,
runtime,
workspace_dir,
workspace_access,
canvas: initialCanvasPosition(),
});
if (isApiError(created)) {
return toMcpResult({
error: "PROVISION_FAILED",
detail: created,
requested_runtime: runtime,
provisioned: false,
});
}
const createdObj = (created ?? {}) as Record<string, unknown>;
const workspaceId =
typeof createdObj.id === "string" ? createdObj.id : undefined;
if (!workspaceId) {
return toMcpResult({
error: "PROVISION_FAILED",
detail: "create succeeded but no workspace id returned; cannot verify resolved runtime",
requested_runtime: runtime,
create_response: created,
provisioned: false,
});
}
// (3) Read back and assert request == delivered. The create response
// does not always echo the persisted runtime, so re-fetch the row.
const fetched = await platformGet(`/workspaces/${workspaceId}`);
let resolvedRuntime: string | undefined;
if (!isApiError(fetched) && fetched && typeof fetched === "object") {
const f = fetched as Record<string, unknown>;
if (typeof f.runtime === "string") resolvedRuntime = f.runtime;
}
// BYO-compute runtimes may be normalized (e.g. "" -> "external");
// treat the requested value as authoritative for those.
const requestedIsByo =
runtime === "external" || runtime === "kimi" || runtime === "kimi-cli";
if (resolvedRuntime === undefined) {
return toMcpResult({
error: "PROVISION_UNVERIFIED",
detail:
"workspace was created but its resolved runtime could not be read back; " +
"treat as NOT verified — do not assume the requested runtime was honored",
workspace_id: workspaceId,
requested_runtime: runtime,
provisioned: false,
});
}
if (!requestedIsByo && resolvedRuntime !== runtime) {
return toMcpResult({
error: "RUNTIME_MISMATCH",
detail:
`requested runtime "${runtime}" but the platform provisioned ` +
`"${resolvedRuntime}" (silent fallback — this is the #184 / ` +
`controlplane#188 contract violation). The workspace exists but ` +
`is the WRONG runtime; delete it and escalate (platform hard-gate ` +
`not yet shipped).`,
workspace_id: workspaceId,
requested_runtime: runtime,
resolved_runtime: resolvedRuntime,
provisioned: false,
});
}
// (4) Optional role-config application + read-back-assert. Runtime is
// verified above; now fold in the per-role config so "create" and
// "apply-role-config" are ONE fail-closed operation instead of two
// (the #218 prod-team defect: workspaces provisioned with the right
// runtime but template-default role config — generic name, Sonnet
// instead of the role's model, empty charter — because per-role
// config was never applied as part of provisioning).
//
// Mechanism (canonical, source-verified against molecule-core
// workspace-server):
// - model → PUT /workspaces/:id/model (writes the MODEL_PROVIDER
// workspace_secret; AUTHORITATIVE over config.yaml's
// runtime_config.model per the claude-code adapter resolution
// order; auto-restarts). Read back via GET /workspaces/:id/model
// and ASSERT effective == requested — never trust the write-ack.
// - config.yaml (name/description/charter/required_env) → PUT
// /workspaces/:id/files/config.yaml (writes via EIC to the
// workspace EC2 + auto-restarts). NOTE: the GET-back of
// config.yaml resolves a DIFFERENT host/path than the PUT
// (documented asymmetry — molecule-core
// tests/e2e/test_staging_full_saas.sh), so config.yaml content is
// NOT read-back-asserted here; the model read-back is the
// authoritative effective-config gate.
if (params.role_config) {
const rc = params.role_config;
const applied: Record<string, unknown> = {};
if (typeof rc.config_yaml === "string" && rc.config_yaml.length > 0) {
const w = await apiCall(
"PUT",
`/workspaces/${workspaceId}/files/config.yaml`,
{ content: rc.config_yaml }
);
if (isApiError(w)) {
return toMcpResult({
error: "ROLE_CONFIG_FAILED",
detail: w,
phase: "config.yaml",
workspace_id: workspaceId,
requested_runtime: runtime,
resolved_runtime: resolvedRuntime,
provisioned: true,
role_config_applied: false,
});
}
applied.config_yaml = "written";
}
if (typeof rc.model === "string" && rc.model.length > 0) {
const m = await apiCall("PUT", `/workspaces/${workspaceId}/model`, {
model: rc.model,
});
if (isApiError(m)) {
return toMcpResult({
error: "ROLE_CONFIG_FAILED",
detail: m,
phase: "model",
workspace_id: workspaceId,
requested_runtime: runtime,
resolved_runtime: resolvedRuntime,
provisioned: true,
role_config_applied: false,
});
}
// Read-back-assert the EFFECTIVE model — not the write-ack.
const mb = await platformGet(`/workspaces/${workspaceId}/model`);
let effectiveModel: string | undefined;
if (!isApiError(mb) && mb && typeof mb === "object") {
const v = (mb as Record<string, unknown>).model;
if (typeof v === "string") effectiveModel = v;
}
if (effectiveModel !== rc.model) {
return toMcpResult({
error: "ROLE_CONFIG_MODEL_MISMATCH",
detail:
`requested model "${rc.model}" but read-back returned ` +
`"${effectiveModel ?? "<unreadable>"}" — the role's model was ` +
`NOT applied; treat as NOT configured (do not assume the ` +
`requested model is in effect).`,
workspace_id: workspaceId,
requested_model: rc.model,
effective_model: effectiveModel ?? null,
requested_runtime: runtime,
resolved_runtime: resolvedRuntime,
provisioned: true,
role_config_applied: false,
});
}
applied.model = effectiveModel;
}
return toMcpResult({
ok: true,
provisioned: true,
role_config_applied: true,
workspace_id: workspaceId,
requested_runtime: runtime,
resolved_runtime: resolvedRuntime,
template: template || null,
applied,
status: createdObj.status ?? "provisioning",
});
}
return toMcpResult({
ok: true,
provisioned: true,
role_config_applied: false,
workspace_id: workspaceId,
requested_runtime: runtime,
resolved_runtime: resolvedRuntime,
template: template || null,
status: createdObj.status ?? "provisioning",
});
}
export async function handleGetWorkspace(params: { workspace_id: string }) {
const data = await platformGet(`/workspaces/${params.workspace_id}`);
return toMcpResult(data);
@@ -90,6 +382,49 @@ export function registerWorkspaceTools(srv: McpServer) {
handleCreateWorkspace
);
srv.tool(
"provision_workspace",
"Provision a workspace with a SPECIFIC runtime (claude-code, codex, hermes, openclaw, langgraph, autogen, crewai, deepagents) via the correct product create path. Fail-closed: validates the runtime, then reads the created workspace back and returns an error (not a success) if the platform silently fell back to a different runtime. Use this — not create_workspace — when the runtime must be guaranteed.",
{
name: z.string().describe("Workspace name"),
runtime: z
.enum(SUPPORTED_RUNTIMES)
.describe("Required runtime — provisioning fails closed if it cannot be honored"),
template: z
.string()
.optional()
.describe("Template name (defaults to '<runtime>-default'); overrides runtime-derived template"),
tier: z.number().min(1).max(4).optional().describe("Tier (1=basic, 2=browser, 3=desktop, 4=VM). SaaS forces T4."),
role: z.string().optional().describe("Role description"),
parent_id: z.string().optional().describe("Parent workspace ID for nesting"),
workspace_dir: z.string().optional().describe("Host path to bind-mount at /workspace"),
workspace_access: z
.enum(["none", "read_only", "read_write"])
.optional()
.describe("Filesystem access mode for /workspace"),
role_config: z
.object({
model: z
.string()
.optional()
.describe(
"Effective model slug for this role (e.g. 'opus', 'kimi-for-coding', 'MiniMax-M2.7', 'gpt-5.5'). Applied via PUT /model (authoritative over config.yaml) and read-back-asserted — provisioning fails closed if the effective model does not match."
),
config_yaml: z
.string()
.optional()
.describe(
"Full config.yaml content for the role (name, description/charter, runtime_config.model, required_env). Written via the Files API; preserve the template's providers registry. NOT read-back-asserted (PUT/GET path asymmetry) — the model read-back is the effective-config gate."
),
})
.optional()
.describe(
"Optional per-role config applied + verified as part of the SAME fail-closed provision op. Without this, a workspace can be the right runtime but carry template-default role config (the #218 defect)."
),
},
handleProvisionWorkspace
);
srv.tool(
"get_workspace",
"Get detailed information about a specific workspace",
-96
View File
@@ -290,99 +290,3 @@ describe("platformGet", () => {
});
});
});
// ---------------------------------------------------------------------------
// remote_agents — handleGetRemoteAgentSetupCommand
// ---------------------------------------------------------------------------
// remote_agents.ts reads PLATFORM_URL at module-load time from process.env.
// We use jest.isolateModules so each test gets a fresh module context with
// the right env var set before the module is loaded.
const originalEnv = process.env.MOLECULE_API_URL;
describe("handleGetRemoteAgentSetupCommand", () => {
beforeEach(() => {
jest.resetModules();
process.env.MOLECULE_API_URL = "http://localhost:8080";
});
afterEach(() => {
if (originalEnv === undefined) {
delete process.env.MOLECULE_API_URL;
} else {
process.env.MOLECULE_API_URL = originalEnv;
}
});
async function loadHandlerAndMock(workspace: Record<string, unknown>) {
let handler!: typeof import("../../src/tools/remote_agents").handleGetRemoteAgentSetupCommand;
let mockGet!: jest.Mock;
await new Promise<void>((resolve) => {
jest.isolateModules(() => {
mockGet = jest.fn().mockResolvedValue(workspace);
jest.mock("../../src/api", () => ({
...jest.requireActual("../../src/api"),
platformGet: mockGet,
}));
const mod = require("../../src/tools/remote_agents");
handler = mod.handleGetRemoteAgentSetupCommand;
resolve();
});
});
return { handler, mockGet };
}
it("generates valid Python command with constructor + register pattern", async () => {
const { handler } = await loadHandlerAndMock({
id: "ws-abc123",
name: "my-agent",
runtime: "external",
});
const result = await handler({ workspace_id: "ws-abc123" });
const parsed = JSON.parse((result.content[0] as { text: string }).text);
expect(parsed.workspace_id).toBe("ws-abc123");
expect(parsed.workspace_name).toBe("my-agent");
expect(parsed.setup_command).toContain("RemoteAgentClient(workspace_id='ws-abc123'");
expect(parsed.setup_command).not.toContain("register_from_env");
expect(parsed.setup_command).toContain("register()");
});
it("warns when PLATFORM_URL is localhost and no override is given", async () => {
const { handler } = await loadHandlerAndMock({
id: "ws-abc123",
name: "my-agent",
runtime: "external",
});
const result = await handler({ workspace_id: "ws-abc123" });
const parsed = JSON.parse((result.content[0] as { text: string }).text);
expect(parsed.warnings).toBeDefined();
expect(parsed.warnings![0]).toContain("localhost");
});
it("uses platform_url_override when provided", async () => {
const { handler } = await loadHandlerAndMock({
id: "ws-abc123",
name: "my-agent",
runtime: "external",
});
const result = await handler({
workspace_id: "ws-abc123",
platform_url_override: "https://platform.example.com",
});
const parsed = JSON.parse((result.content[0] as { text: string }).text);
expect(parsed.setup_command).toContain("platform_url='https://platform.example.com'");
expect(parsed.warnings).toBeUndefined();
});
it("returns error when workspace is not runtime=external", async () => {
const { handler } = await loadHandlerAndMock({
id: "ws-abc123",
name: "my-agent",
runtime: "docker",
});
const result = await handler({ workspace_id: "ws-abc123" });
const parsed = JSON.parse((result.content[0] as { text: string }).text);
expect(parsed.error).toContain("not external");
expect(parsed.setup_command).toBeUndefined();
});
});
-164
View File
@@ -1,164 +0,0 @@
/**
* Unit tests for src/tools/remote_agents.ts
*
* Tests handleGetRemoteAgentSetupCommand which generates a Python bootstrap
* command for remote agents. Key edge cases:
* - localhost warning when PLATFORM_URL is localhost and no override given
* - platform_url_override bypasses localhost warning
* - non-external runtime returns error
* - workspace not found returns error
*/
import { toMcpResult } from "../../src/api";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Factory so each fetch call gets a fresh Response (bodies can only be read once). */
function makeFetchResponse(body: unknown, init: ResponseInit = {}): Response {
const text = typeof body === "string" ? body : JSON.stringify(body);
return new Response(text, {
status: init.status ?? 200,
statusText: init.statusText,
headers: init.headers as HeadersInit,
});
}
type RemoteAgentsHandler = {
handleGetRemoteAgentSetupCommand: (
params: { workspace_id: string; platform_url_override?: string }
) => Promise<ReturnType<typeof toMcpResult>>;
};
/**
* Dynamically import the remote_agents module with a mocked platformGet.
* Must be called inside jest.isolateModules() with MOLECULE_API_URL set.
*/
async function loadHandlerWithMock(
mockPlatformGet: jest.Mock,
): Promise<RemoteAgentsHandler> {
let handler!: RemoteAgentsHandler;
await new Promise<void>((resolve) => {
jest.isolateModules(() => {
jest.mock("../../src/api", () => ({
...jest.requireActual("../../src/api"),
platformGet: mockPlatformGet,
}));
const mod = require("../../src/tools/remote_agents") as RemoteAgentsHandler;
handler = mod;
resolve();
});
});
return handler;
}
// ---------------------------------------------------------------------------
// handleGetRemoteAgentSetupCommand tests
// ---------------------------------------------------------------------------
describe("handleGetRemoteAgentSetupCommand", () => {
beforeEach(() => {
jest.resetModules();
});
it("returns a setup command with correct RemoteAgentClient API call", async () => {
const mockGet = jest.fn().mockResolvedValue({
id: "ws-abc123",
name: "test-agent",
runtime: "external",
});
const handler = await loadHandlerWithMock(mockGet);
const result = await handler.handleGetRemoteAgentSetupCommand({
workspace_id: "ws-abc123",
});
expect(result.content[0].text).toContain("ws-abc123");
expect(result.content[0].text).toContain("molecule_agent import RemoteAgentClient");
// Must use constructor + load_token pattern, NOT the non-existent register_from_env()
expect(result.content[0].text).not.toContain("register_from_env()");
expect(result.content[0].text).toContain("load_token()");
expect(result.content[0].text).toContain("pull_secrets()");
expect(result.content[0].text).toContain("run_heartbeat_loop()");
});
it("returns a localhost warning when PLATFORM_URL is localhost and no override given", async () => {
// Set localhost as the platform URL before loading the module
process.env.MOLECULE_API_URL = "http://localhost:8080";
const mockGet = jest.fn().mockResolvedValue({
id: "ws-abc123",
name: "test-agent",
runtime: "external",
});
const handler = await loadHandlerWithMock(mockGet);
const result = await handler.handleGetRemoteAgentSetupCommand({
workspace_id: "ws-abc123",
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.warnings).toBeDefined();
expect(parsed.warnings[0]).toContain("localhost");
expect(parsed.warnings[0]).toContain("platform_url_override");
delete process.env.MOLECULE_API_URL;
});
it("platform_url_override bypasses the localhost warning", async () => {
// Even with localhost as the base URL, passing an override suppresses the warning
process.env.MOLECULE_API_URL = "http://localhost:8080";
const mockGet = jest.fn().mockResolvedValue({
id: "ws-abc123",
name: "test-agent",
runtime: "external",
});
const handler = await loadHandlerWithMock(mockGet);
const result = await handler.handleGetRemoteAgentSetupCommand({
workspace_id: "ws-abc123",
platform_url_override: "https://platform.example.com",
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.warnings).toBeUndefined();
expect(parsed.platform_url).toBe("https://platform.example.com");
delete process.env.MOLECULE_API_URL;
});
it("returns error when workspace runtime is not 'external'", async () => {
const mockGet = jest.fn().mockResolvedValue({
id: "ws-abc123",
name: "docker-agent",
runtime: "docker",
});
const handler = await loadHandlerWithMock(mockGet);
const result = await handler.handleGetRemoteAgentSetupCommand({
workspace_id: "ws-abc123",
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error).toContain("not external");
expect(parsed.error).toContain("runtime='external'");
expect(parsed.actual_runtime).toBe("docker");
});
it("returns error when workspace is not found", async () => {
const mockGet = jest.fn().mockResolvedValue({
error: "not found",
detail: "workspace ws-missing does not exist",
});
const handler = await loadHandlerWithMock(mockGet);
const result = await handler.handleGetRemoteAgentSetupCommand({
workspace_id: "ws-missing",
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error).toBeDefined();
});
});