0ec3db81e6
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>
313 lines
9.1 KiB
Go
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
|
|
}
|