Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36ed15fef2 | |||
| 6f7250ee94 |
@@ -96,10 +96,7 @@ The workflow:
|
||||
|
||||
### APIs Connected
|
||||
|
||||
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.
|
||||
The server connects to the Molecule AI platform REST API. See the platform SDK (`../molecule-sdk-python`) for the underlying API client used.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
@@ -113,7 +110,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 REST API — it does not connect to Postgres directly.
|
||||
Platform data lives in Postgres (source of truth). The server reads data via the platform SDK — it does not connect to Postgres directly.
|
||||
|
||||
## TypeScript Conventions
|
||||
|
||||
|
||||
+2
-183
@@ -26,7 +26,6 @@ import {
|
||||
PLATFORM_URL,
|
||||
handleListWorkspaces,
|
||||
handleCreateWorkspace,
|
||||
handleProvisionWorkspace,
|
||||
handleGetWorkspace,
|
||||
handleDeleteWorkspace,
|
||||
handleRestartWorkspace,
|
||||
@@ -120,186 +119,6 @@ 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
|
||||
// ============================================================
|
||||
@@ -1040,12 +859,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 (88) rather than a lower bound so a
|
||||
// the concrete current tool count (87) 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(88);
|
||||
expect(names.length).toBe(87);
|
||||
// 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);
|
||||
|
||||
+1
-2
@@ -38,7 +38,6 @@ export {
|
||||
registerWorkspaceTools,
|
||||
handleListWorkspaces,
|
||||
handleCreateWorkspace,
|
||||
handleProvisionWorkspace,
|
||||
handleGetWorkspace,
|
||||
handleDeleteWorkspace,
|
||||
handleRestartWorkspace,
|
||||
@@ -213,7 +212,7 @@ async function main() {
|
||||
const server = createServer();
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
logInfo("Molecule AI MCP server running on stdio (88 tools available)", { transport: "stdio", toolCount: 88 });
|
||||
logInfo("Molecule AI MCP server running on stdio (87 tools available)", { transport: "stdio", toolCount: 87 });
|
||||
}
|
||||
|
||||
// Only auto-start when run directly (not when imported for testing).
|
||||
|
||||
@@ -90,13 +90,14 @@ export async function handleGetRemoteAgentSetupCommand(params: {
|
||||
`WORKSPACE_ID=${w.id} \\`,
|
||||
`PLATFORM_URL=${targetUrl} \\`,
|
||||
`python3 -c "from molecule_agent import RemoteAgentClient; \\`,
|
||||
` c = RemoteAgentClient.register_from_env(); \\`,
|
||||
` c = RemoteAgentClient(workspace_id='${w.id}', platform_url='${targetUrl}'); \\`,
|
||||
` if c.load_token() is None: c.register(); \\`,
|
||||
` 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 its bearer token (cached at`,
|
||||
`# The agent will register (mint + cache bearer token at`,
|
||||
`# ~/.molecule/${w.id}/.auth_token), pull secrets, then heartbeat.`,
|
||||
].join("\n");
|
||||
return toMcpResult({
|
||||
|
||||
+1
-336
@@ -1,43 +1,6 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
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`;
|
||||
}
|
||||
import { apiCall, platformGet, toMcpResult } from "../api.js";
|
||||
|
||||
export async function handleListWorkspaces() {
|
||||
const data = await platformGet("/workspaces");
|
||||
@@ -69,261 +32,6 @@ 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);
|
||||
@@ -382,49 +90,6 @@ 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",
|
||||
|
||||
@@ -290,3 +290,99 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user