From 549c15c594897a298b4b1737c3694917ec82b04d Mon Sep 17 00:00:00 2001 From: core-devops Date: Mon, 18 May 2026 01:19:48 -0700 Subject: [PATCH 1/2] feat(workspaces): add fail-closed provision_workspace MCP tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/__tests__/index.test.ts | 109 ++++++++++++++++++- src/index.ts | 3 +- src/tools/workspaces.ts | 208 +++++++++++++++++++++++++++++++++++- 3 files changed, 316 insertions(+), 4 deletions(-) diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index b191317..8d3d30c 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -26,6 +26,7 @@ import { PLATFORM_URL, handleListWorkspaces, handleCreateWorkspace, + handleProvisionWorkspace, handleGetWorkspace, handleDeleteWorkspace, handleRestartWorkspace, @@ -119,6 +120,110 @@ 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); + }); +}); + // ============================================================ // apiCall() tests // ============================================================ @@ -859,12 +964,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); diff --git a/src/index.ts b/src/index.ts index 4f7380c..0c943a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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). diff --git a/src/tools/workspaces.ts b/src/tools/workspaces.ts index ee65309..806609a 100644 --- a/src/tools/workspaces.ts +++ b/src/tools/workspaces.ts @@ -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,151 @@ 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"; +}) { + 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 + // "-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; + 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; + 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, + }); + } + + return toMcpResult({ + ok: true, + provisioned: true, + 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 +272,30 @@ 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 '-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"), + }, + handleProvisionWorkspace + ); + srv.tool( "get_workspace", "Get detailed information about a specific workspace", -- 2.52.0 From 8e64f9f107a7fc97d4ace42533cdda5c6a7e83ea Mon Sep 17 00:00:00 2001 From: core-devops Date: Mon, 18 May 2026 02:10:12 -0700 Subject: [PATCH 2/2] feat(workspaces): fold apply-role-config + read-back-assert into provision_workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/__tests__/index.test.ts | 76 +++++++++++++++++++++ src/tools/workspaces.ts | 129 ++++++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 8d3d30c..caa655c 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -222,6 +222,82 @@ describe("handleProvisionWorkspace (fail-closed contract)", () => { 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); + }); }); // ============================================================ diff --git a/src/tools/workspaces.ts b/src/tools/workspaces.ts index 806609a..5338787 100644 --- a/src/tools/workspaces.ts +++ b/src/tools/workspaces.ts @@ -107,6 +107,10 @@ export async function handleProvisionWorkspace(params: { 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; @@ -203,9 +207,115 @@ export async function handleProvisionWorkspace(params: { }); } + // (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 = {}; + + 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).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 ?? ""}" — 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, @@ -292,6 +402,25 @@ export function registerWorkspaceTools(srv: McpServer) { .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 ); -- 2.52.0