Files
molecule-cli/internal/client/platform.go
sdk-dev 0ec3db81e6
CI / Test / test (pull_request) Successful in 2m3s
Release Go binaries / test (pull_request) Successful in 2m9s
Release Go binaries / release (pull_request) Waiting to run
fix(cli): address CR2 review on #13 — path-escaping, config binding, CP-admin targeting
CR2 review findings on PR #13 (branch feat/management-cli-verbs):

1. [HIGH] PathEscape user-controlled path segments. platform.go built paths
   via fmt.Sprintf on raw caller IDs (GetWorkspace/DeleteWorkspace/
   RestartWorkspace/ListWorkspaceAgents/GetAgent/GetPeers/GetDelegations) and
   the agent-send / workspace-delegate runHTTP call sites concatenated raw IDs.
   An ID with '/', '?' or '#' could alter the endpoint or leak into the query.
   Wrapped every caller-supplied segment in url.PathEscape (management.go
   already did this). DeleteWorkspace's ?confirm=true is now injection-safe.
   Severity note: this runs under the user's own management creds, so it is
   primarily robustness/correctness rather than a privilege-escalation hole.

2. [MED] Config not bound to globals. viper read the config file but the
   flag-backed apiURL/outputFormat globals were never populated from it, so
   `molecule config set api_url` did not affect newClient()/cpURL(). Added
   applyConfigDefaults(): config file is adopted only when no env override and
   the global is still at its built-in default, so precedence stays
   flag > env > config file > default.

3. [MED] MintWorkspaceToken sent a nil body → JSON `null`. Now sends an empty
   object (struct{}{}) → `{}`, matching sibling tooling and avoiding rejection
   by a handler that decodes into a struct/map.

4. [MED] cpURL defaulted to apiURL (tenant host), so an unset MOLECULE_CP_URL
   would send the privileged CP-admin bearer to a tenant host. cpURL() no
   longer falls back to apiURL; cpAdminClient() now requires an explicit
   MOLECULE_CP_URL and fails fast otherwise. Updated org.go help text.

5. [LOW] config set now os.MkdirAll's the config dir before WriteConfig/
   SafeWriteConfig, which otherwise fail on a fresh machine where ~/.config
   doesn't exist yet.

Tests: added path-segment escaping coverage (platform + delete), MintWorkspaceToken
body={}, applyConfigDefaults precedence, config-set mkdir, and CP-admin credential
targeting; retargeted TestCPURLFallback → TestCPURLNoTenantFallback.
go build/vet/test all green; gofmt clean on edited files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:44:52 -07:00

313 lines
9.1 KiB
Go

// Package client provides a thin HTTP client for the Molecule AI platform API.
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
// Platform is the root API client.
type Platform struct {
BaseURL string
// Token is the management API key (org-scoped tenant-admin "Org API Key").
// Sent as `Authorization: Bearer <Token>` on every request. Empty on a
// fresh self-host/dev tenant that doesn't enforce auth.
Token string
// OrgID satisfies the tenant's X-Molecule-Org-Id routing gate. Sent only
// when non-empty so single-tenant hosts keep working.
OrgID string
client *http.Client
}
// New returns a Platform client configured with baseURL and no auth.
// Retained for callers (and tests) that talk to an unauthenticated host.
func New(baseURL string) *Platform {
return &Platform{
BaseURL: baseURL,
client: &http.Client{Timeout: 30 * time.Second},
}
}
// NewWithAuth returns a Platform client that attaches the management API key
// (and org-id header when set) to every request. This closes the auth gap
// that previously 401'd management calls against a hardened tenant.
func NewWithAuth(baseURL, token, orgID string) *Platform {
return &Platform{
BaseURL: baseURL,
Token: token,
OrgID: orgID,
client: &http.Client{Timeout: 30 * time.Second},
}
}
// setAuth attaches the management credentials to req. Centralized so every
// helper (GET/POST/DELETE/PUT/PATCH) authenticates identically.
func (p *Platform) setAuth(req *http.Request) {
if p.Token != "" {
req.Header.Set("Authorization", "Bearer "+p.Token)
}
if p.OrgID != "" {
req.Header.Set("X-Molecule-Org-Id", p.OrgID)
}
}
// Workspace represents a Molecule AI workspace.
type Workspace struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Role string `json:"role,omitempty"`
ParentID string `json:"parent_id,omitempty"`
Runtime string `json:"runtime,omitempty"`
WorkspaceDir string `json:"workspace_dir,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
Tier int `json:"tier,omitempty"`
Canvas *Canvas `json:"canvas,omitempty"`
}
// Canvas holds the workspace's position on the canvas.
type Canvas struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
// Agent represents an agent running in a workspace.
type Agent struct {
ID string `json:"id"`
Name string `json:"name"`
WorkspaceID string `json:"workspace_id,omitempty"`
Status string `json:"status"`
Model string `json:"model,omitempty"`
Runtime string `json:"runtime,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
}
// CreateWorkspaceRequest mirrors the platform's POST /workspaces body.
type CreateWorkspaceRequest struct {
Name string `json:"name"`
Role string `json:"role,omitempty"`
Template string `json:"template,omitempty"`
Tier int `json:"tier,omitempty"`
ParentID string `json:"parent_id,omitempty"`
Runtime string `json:"runtime,omitempty"`
WorkspaceDir string `json:"workspace_dir,omitempty"`
}
// PlatformHealth holds the /health endpoint response.
type PlatformHealth struct {
Status string `json:"status"`
Version string `json:"version,omitempty"`
Uptime string `json:"uptime,omitempty"`
Database string `json:"database,omitempty"`
}
// ConfigEntry represents a config key-value pair.
type ConfigEntry struct {
Key string `json:"key"`
Value string `json:"value,omitempty"`
}
// ListWorkspaces returns all workspaces in the org.
func (p *Platform) ListWorkspaces() ([]Workspace, error) {
var out []Workspace
if err := p.getInto("/workspaces", &out); err != nil {
return nil, err
}
return out, nil
}
// GetWorkspace returns a single workspace by ID.
func (p *Platform) GetWorkspace(id string) (*Workspace, error) {
var out Workspace
if err := p.getInto("/workspaces/"+url.PathEscape(id), &out); err != nil {
return nil, err
}
return &out, nil
}
// CreateWorkspace creates a new workspace.
func (p *Platform) CreateWorkspace(req CreateWorkspaceRequest) (*Workspace, error) {
var out Workspace
if err := p.postInto("/workspaces", req, &out); err != nil {
return nil, err
}
return &out, nil
}
// DeleteWorkspace deletes a workspace by ID.
func (p *Platform) DeleteWorkspace(id string) error {
_, err := p.delete("/workspaces/" + url.PathEscape(id) + "?confirm=true")
return err
}
// RestartWorkspace triggers a restart for a workspace.
func (p *Platform) RestartWorkspace(id string) error {
_, err := p.postEmpty("/workspaces/" + url.PathEscape(id) + "/restart")
return err
}
// ListAgents returns all agents across the org.
func (p *Platform) ListAgents() ([]Agent, error) {
var out []Agent
if err := p.getInto("/agents", &out); err != nil {
return nil, err
}
return out, nil
}
// ListWorkspaceAgents returns agents for a given workspace.
func (p *Platform) ListWorkspaceAgents(workspaceID string) ([]Agent, error) {
var out []Agent
if err := p.getInto("/workspaces/"+url.PathEscape(workspaceID)+"/agents", &out); err != nil {
return nil, err
}
return out, nil
}
// GetAgent returns a single agent by ID.
func (p *Platform) GetAgent(id string) (*Agent, error) {
var out Agent
if err := p.getInto("/agents/"+url.PathEscape(id), &out); err != nil {
return nil, err
}
return &out, nil
}
// Health returns the platform's /health status.
func (p *Platform) Health() (*PlatformHealth, error) {
var out PlatformHealth
if err := p.getInto("/health", &out); err != nil {
return nil, err
}
return &out, nil
}
// AuditWorkspaces returns all workspaces and agents.
func (p *Platform) AuditWorkspaces() ([]Workspace, []Agent, error) {
ws, err := p.ListWorkspaces()
if err != nil {
return nil, nil, fmt.Errorf("audit workspaces: %w", err)
}
agents, err := p.ListAgents()
if err != nil {
return ws, nil, fmt.Errorf("audit agents: %w", err)
}
return ws, agents, nil
}
// GetPeers returns peer workspaces reachable from a workspace.
func (p *Platform) GetPeers(workspaceID string) ([]Agent, error) {
var out []Agent
if err := p.getInto("/registry/"+url.PathEscape(workspaceID)+"/peers", &out); err != nil {
return nil, err
}
return out, nil
}
// GetDelegations returns delegation status for a workspace.
func (p *Platform) GetDelegations(workspaceID string) ([]map[string]interface{}, error) {
var out []map[string]interface{}
if err := p.getInto("/workspaces/"+url.PathEscape(workspaceID)+"/delegations", &out); err != nil {
return nil, err
}
return out, nil
}
// ---------------------------------------------------------------------------
// Private HTTP helpers
// ---------------------------------------------------------------------------
func (p *Platform) getInto(path string, out interface{}) error {
url := p.BaseURL + path
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("new GET request: %w", err)
}
p.setAuth(req)
resp, err := p.client.Do(req)
if err != nil {
return fmt.Errorf("GET %s: %w", url, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("GET %s: HTTP %d — %s", url, resp.StatusCode, string(body))
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("decode GET %s: %w", path, err)
}
return nil
}
func (p *Platform) postInto(path string, body interface{}, out interface{}) error {
encoded, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal POST body: %w", err)
}
url := p.BaseURL + path
req, err := http.NewRequest("POST", url, bytes.NewReader(encoded))
if err != nil {
return fmt.Errorf("new POST request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
p.setAuth(req)
resp, err := p.client.Do(req)
if err != nil {
return fmt.Errorf("POST %s: %w", url, err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("POST %s: HTTP %d — %s", url, resp.StatusCode, string(respBody))
}
if out != nil {
if err := json.Unmarshal(respBody, out); err != nil {
return fmt.Errorf("decode POST %s response: %w", path, err)
}
}
return nil
}
func (p *Platform) delete(path string) ([]byte, error) {
url := p.BaseURL + path
req, err := http.NewRequest("DELETE", url, nil)
if err != nil {
return nil, fmt.Errorf("new DELETE request: %w", err)
}
p.setAuth(req)
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("DELETE %s: %w", url, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("DELETE %s: HTTP %d — %s", url, resp.StatusCode, string(body))
}
return body, nil
}
func (p *Platform) postEmpty(path string) ([]byte, error) {
url := p.BaseURL + path
req, err := http.NewRequest("POST", url, nil)
if err != nil {
return nil, fmt.Errorf("new POST request: %w", err)
}
p.setAuth(req)
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("POST %s: %w", url, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("POST %s: HTTP %d — %s", url, resp.StatusCode, string(body))
}
return body, nil
}