diff --git a/workspace-server/internal/handlers/platform_agent.go b/workspace-server/internal/handlers/platform_agent.go index 74126655..651bb49f 100644 --- a/workspace-server/internal/handlers/platform_agent.go +++ b/workspace-server/internal/handlers/platform_agent.go @@ -256,8 +256,8 @@ func (h *WorkspaceHandler) applyConciergeProvisionConfig( // is kind-gated) → it is the primary entitlement gate for the privileged // org-admin MCP; recordDeclaredPlugin fail-closes the same name for any // non-platform workspace as defense-in-depth. Idempotent (upsert). - if rec, skip := seedTemplatePlugins(ctx, workspaceID, []string{conciergePlatformMCPPlugin}); skip > 0 { - log.Printf("Provisioner: concierge %s could not declare %q plugin (recorded=%d skipped=%d) — management MCP may be absent until next provision", workspaceID, conciergePlatformMCPPlugin, rec, skip) + if rec, skip := seedTemplatePlugins(ctx, workspaceID, []string{conciergePlatformMCPSource}); skip > 0 { + log.Printf("Provisioner: concierge %s could not declare %q plugin (recorded=%d skipped=%d) — management MCP may be absent until next provision", workspaceID, conciergePlatformMCPSource, rec, skip) } // 1. Platform-MCP env (org-admin token + platform URL + org id). @@ -407,17 +407,36 @@ const conciergeProvider = "platform" // model resolves cleanly on its own (e.g. `sonnet` -> anthropic-oauth). const platformManagedModelPrefix = "moonshot/" -// conciergePlatformMCPPlugin is the management-MCP plugin the concierge declares -// (repo molecule-ai-plugin-molecule-platform-mcp). It wires the `molecule-mcp` +// The management-MCP plugin the concierge declares. It wires the `molecule-mcp` // server (MOLECULE_MCP_MODE=management — create_workspace, list_workspaces, …) // into the Claude Code runtime via the plugin channel's MCPServerAdaptor, // replacing the baked-image + asset-channel mcp_servers.yaml path that does NOT -// reach the on-box config (RFC: rfc-platform-mcp-as-plugin). Declaring it here — -// from the kind=platform-only applyConciergeProvisionConfig — IS the primary +// reach the on-box config (RFC: rfc-platform-mcp-as-plugin). Declaring it from +// the kind=platform-only applyConciergeProvisionConfig IS the primary // entitlement gate (no user workspace runs this path); recordDeclaredPlugin adds // a defense-in-depth refusal for this PRIVILEGED name on any non-platform // workspace. The post-online reconcile + boot-install then install it. -const conciergePlatformMCPPlugin = "molecule-platform-mcp" +// +// conciergePlatformMCPSource MUST be a gitea:// source, not a bare name: the +// box's boot-install (runtime-image entrypoint) ONLY fetches gitea:// sources +// and SKIPS anything else ("skip unsupported source"). A bare name parses to the +// `local` scheme, which only resolves plugins baked into the image — and this is +// a brand-new Gitea-only plugin repo, so a bare name would never be fetched. +// +// It MUST also carry a pinned #ref: the gitea resolver rejects an unpinned spec +// in production (PLUGIN_ALLOW_UNPINNED is unset by default — see plugins/gitea.go), +// so an unpinned source would record the declaration but then FAIL to fetch at +// boot-install time → no management MCP, no create_workspace. #main matches the +// established seo-all convention (gitea.go example). The #ref does NOT affect +// PluginNameFromSource, so conciergePlatformMCPName below is unchanged. +const conciergePlatformMCPSource = "gitea://molecule-ai/molecule-ai-plugin-molecule-platform-mcp#main" + +// conciergePlatformMCPName is the install NAME plugins.PluginNameFromSource +// derives from the gitea:// source above (the repo segment, no subpath). It is +// what gets written to workspace_declared_plugins.plugin_name and what the +// recordDeclaredPlugin entitlement gate matches on — so it MUST equal the +// derivation, not the human label "molecule-platform-mcp". +const conciergePlatformMCPName = "molecule-ai-plugin-molecule-platform-mcp" // ensureConciergeProvider pins the concierge's LLM provider to `platform` (core // companion to ensureConciergeModel). It guarantees the env-level provider pin diff --git a/workspace-server/internal/handlers/platform_agent_test.go b/workspace-server/internal/handlers/platform_agent_test.go index 53e0b0dc..d3700905 100644 --- a/workspace-server/internal/handlers/platform_agent_test.go +++ b/workspace-server/internal/handlers/platform_agent_test.go @@ -937,9 +937,9 @@ func TestRecordDeclaredPlugin_PrivilegedPluginEntitlement(t *testing.T) { mock.ExpectQuery(kindQuery).WithArgs("ws-concierge"). WillReturnRows(sqlmock.NewRows([]string{"kind"}).AddRow("platform")) mock.ExpectExec(declaredInsert). - WithArgs("ws-concierge", conciergePlatformMCPPlugin, sqlmock.AnyArg()). + WithArgs("ws-concierge", conciergePlatformMCPName, sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(0, 1)) - if err := recordDeclaredPlugin(context.Background(), "ws-concierge", conciergePlatformMCPPlugin, conciergePlatformMCPPlugin); err != nil { + if err := recordDeclaredPlugin(context.Background(), "ws-concierge", conciergePlatformMCPName, conciergePlatformMCPSource); err != nil { t.Fatalf("platform concierge declaration of the management MCP must succeed: %v", err) } if err := mock.ExpectationsWereMet(); err != nil { @@ -952,7 +952,7 @@ func TestRecordDeclaredPlugin_PrivilegedPluginEntitlement(t *testing.T) { mock.ExpectQuery(kindQuery).WithArgs("ws-user"). WillReturnRows(sqlmock.NewRows([]string{"kind"}).AddRow("workspace")) // NO ExpectExec: the gate MUST refuse before any INSERT fires. - err := recordDeclaredPlugin(context.Background(), "ws-user", conciergePlatformMCPPlugin, conciergePlatformMCPPlugin) + err := recordDeclaredPlugin(context.Background(), "ws-user", conciergePlatformMCPName, conciergePlatformMCPSource) if err == nil { t.Fatal("a non-platform workspace MUST NOT be able to declare the privileged management MCP plugin") } diff --git a/workspace-server/internal/handlers/plugins_tracking.go b/workspace-server/internal/handlers/plugins_tracking.go index b1f1f595..60be1a80 100644 --- a/workspace-server/internal/handlers/plugins_tracking.go +++ b/workspace-server/internal/handlers/plugins_tracking.go @@ -112,7 +112,7 @@ func recordDeclaredPlugin(ctx context.Context, workspaceID, pluginName, sourceRa // seed, org_import, a user-authored workspace.yaml), so refusing it here for a // non-platform workspace closes the privilege-escalation vector regardless of // declaration source. Fail-closed on a kind read error. - if pluginName == conciergePlatformMCPPlugin { + if pluginName == conciergePlatformMCPName { var kind string if err := db.DB.QueryRowContext(ctx, `SELECT COALESCE(kind, 'workspace') FROM workspaces WHERE id = $1`, workspaceID).Scan(&kind); err != nil {