From a002b412e9275e28498136735846e233ac1b228d Mon Sep 17 00:00:00 2001 From: sdk-dev Date: Sun, 31 May 2026 20:53:04 -0700 Subject: [PATCH 1/4] feat(cli): fix runHTTP auth bug + add management verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix the auth bug FIRST: internal/cmd/http.go runHTTP (and the internal/client Platform HTTP helpers, which had the same gap) sent NO Authorization header, so management calls (workspace create/delete, secrets, tokens, …) 401'd a hardened tenant. Now every request attaches `Authorization: Bearer $MOLECULE_API_KEY` and, when set, `X-Molecule-Org-Id: $MOLECULE_ORG_ID` (the tenant TenantGuard routing gate). Headers are omitted when the env vars are unset so fresh self-host/dev tenants keep working. Regression test TestRunHTTP_SetsAuthHeader asserts the header is set and is proven load-bearing (fails with `Authorization header = ""` when the fix is reverted). Add the management verbs (PLATFORM-MANAGEMENT-API.md §5(b)), each wired to the OpenAPI-documented endpoint at the correct auth tier (verified against the live workspace-server router.go + handlers and controlplane orgs handler, since the parallel feat/openapi-management-spec branch does not exist in molecule-core — reconciled to the actual handler source instead): org list|get|create --slug/--name|create --template|export token list|create|revoke | allowlist workspace list|get|create|delete|restart|pause|resume budget|billing-mode|token mint secret ws list|set|delete org list|set|delete template list|import|refresh bundle export|import events approvals Org-lifecycle verbs target the control plane (MOLECULE_CP_URL, default = api-url); tenant verbs target the tenant host with the Org API Key. All verbs honor --json (and existing -o table|json|yaml). Request/ response shapes match the handler structs (budget USD-cents budget_limits; billing-mode {mode}; org import {dir,mode}; secrets {key,value}; template import {name,files}; etc.). Tests: table-driven request-construction tests (method/path/body/auth) for all 30 management methods against an httptest mock, plus cmd-layer branch tests (budget flag→limits, billing-mode validation, template file mapping, --json resolution, CP-url fallback). Existing workspace/agent/platform commands switched to the authenticated client. go build ./..., go vet ./..., go test ./... all green; gofmt clean on edited files. Binary smoke-tested end-to-end: auth headers reach the server and --json output renders. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/client/management.go | 373 +++++++++++++++++++++++++++++ internal/client/management_test.go | 327 +++++++++++++++++++++++++ internal/client/platform.go | 41 +++- internal/cmd/agent.go | 12 +- internal/cmd/bundle.go | 86 +++++++ internal/cmd/events_approvals.go | 42 ++++ internal/cmd/http.go | 43 +++- internal/cmd/http_test.go | 87 +++++++ internal/cmd/management_test.go | 163 +++++++++++++ internal/cmd/org.go | 292 ++++++++++++++++++++++ internal/cmd/platform.go | 22 +- internal/cmd/root.go | 72 +++++- internal/cmd/secret.go | 132 ++++++++++ internal/cmd/template.go | 114 +++++++++ internal/cmd/workspace.go | 31 +-- internal/cmd/workspace_mgmt.go | 192 +++++++++++++++ 16 files changed, 1988 insertions(+), 41 deletions(-) create mode 100644 internal/client/management.go create mode 100644 internal/client/management_test.go create mode 100644 internal/cmd/bundle.go create mode 100644 internal/cmd/events_approvals.go create mode 100644 internal/cmd/http_test.go create mode 100644 internal/cmd/management_test.go create mode 100644 internal/cmd/org.go create mode 100644 internal/cmd/secret.go create mode 100644 internal/cmd/template.go create mode 100644 internal/cmd/workspace_mgmt.go diff --git a/internal/client/management.go b/internal/client/management.go new file mode 100644 index 0000000..f7365fe --- /dev/null +++ b/internal/client/management.go @@ -0,0 +1,373 @@ +// Package client — management surface (orgs, secrets, tokens, templates, +// bundles, budget, billing-mode, events, approvals). +// +// Request/response shapes are aligned to the live workspace-server handlers +// (tenant) and controlplane handlers (CP). Auth tiers per +// PLATFORM-MANAGEMENT-API.md §1: tenant calls use the Org API Key as a bearer +// (+ X-Molecule-Org-Id); CP org calls use the same key/CP-admin bearer. +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +// --------------------------------------------------------------------------- +// Control-plane: orgs +// --------------------------------------------------------------------------- + +// Org mirrors controlplane models.Organization (GET /api/v1/orgs[/:slug]). +type Org struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Plan string `json:"plan"` + Status string `json:"status"` + CreditsBalance int64 `json:"credits_balance"` + PlanMonthlyCredits int64 `json:"plan_monthly_credits"` + OverageUsedCredits int64 `json:"overage_used_credits"` + OverageCapCredits int64 `json:"overage_cap_credits"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// ListOrgs returns the caller's orgs from the control plane. +func (p *Platform) ListOrgs() ([]Org, error) { + var out []Org + if err := p.getInto("/api/v1/orgs", &out); err != nil { + return nil, err + } + return out, nil +} + +// GetOrg returns a single org by slug from the control plane. +func (p *Platform) GetOrg(slug string) (*Org, error) { + var out Org + if err := p.getInto("/api/v1/orgs/"+url.PathEscape(slug), &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateOrgRequest is the body for POST /api/v1/orgs. +type CreateOrgRequest struct { + Slug string `json:"slug"` + Name string `json:"name"` +} + +// CreateOrg creates an org on the control plane. +func (p *Platform) CreateOrg(req CreateOrgRequest) (*Org, error) { + var out Org + if err := p.postInto("/api/v1/orgs", req, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ExportOrg returns the org export payload (GET /api/v1/orgs/:slug/export). +func (p *Platform) ExportOrg(slug string) (json.RawMessage, error) { + return p.getRaw("/api/v1/orgs/" + url.PathEscape(slug) + "/export") +} + +// --------------------------------------------------------------------------- +// Tenant: org-from-template (POST /org/import) + allowlist + org tokens +// --------------------------------------------------------------------------- + +// ImportOrgRequest is the body for POST /org/import. +type ImportOrgRequest struct { + Dir string `json:"dir,omitempty"` // org template directory name + Mode string `json:"mode,omitempty"` // "merge" (default) | "reconcile" +} + +// CreateOrgFromTemplate provisions workspaces from an org template directory +// (POST /org/import). Returns the raw per-workspace result list. +func (p *Platform) CreateOrgFromTemplate(req ImportOrgRequest) (json.RawMessage, error) { + return p.postRaw("/org/import", req) +} + +// GetAllowlist returns the org plugin allowlist (GET /orgs/:id/plugins/allowlist). +func (p *Platform) GetAllowlist(orgID string) (json.RawMessage, error) { + return p.getRaw("/orgs/" + url.PathEscape(orgID) + "/plugins/allowlist") +} + +// OrgToken is a row from GET /org/tokens. +type OrgToken struct { + ID string `json:"id"` + Prefix string `json:"prefix"` + Name string `json:"name,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + LastUsed string `json:"last_used_at,omitempty"` +} + +// ListOrgTokens lists the org's Org API Keys (GET /org/tokens). +func (p *Platform) ListOrgTokens() ([]OrgToken, error) { + var out []OrgToken + if err := p.getInto("/org/tokens", &out); err != nil { + return nil, err + } + return out, nil +} + +// CreateOrgTokenResponse is the POST /org/tokens response. The plaintext +// token is shown exactly once. +type CreateOrgTokenResponse struct { + ID string `json:"id"` + Prefix string `json:"prefix"` + Name string `json:"name,omitempty"` + Token string `json:"auth_token"` + Warning string `json:"warning,omitempty"` +} + +// CreateOrgToken mints a new Org API Key (POST /org/tokens). +func (p *Platform) CreateOrgToken(name string) (*CreateOrgTokenResponse, error) { + var out CreateOrgTokenResponse + if err := p.postInto("/org/tokens", map[string]string{"name": name}, &out); err != nil { + return nil, err + } + return &out, nil +} + +// RevokeOrgToken revokes an Org API Key by id (DELETE /org/tokens/:id). +func (p *Platform) RevokeOrgToken(id string) error { + _, err := p.delete("/org/tokens/" + url.PathEscape(id)) + return err +} + +// --------------------------------------------------------------------------- +// Tenant: workspace lifecycle (pause/resume) + budget + billing-mode + token +// --------------------------------------------------------------------------- + +// PauseWorkspace pauses a workspace (POST /workspaces/:id/pause). +func (p *Platform) PauseWorkspace(id string) error { + _, err := p.postEmpty("/workspaces/" + url.PathEscape(id) + "/pause") + return err +} + +// ResumeWorkspace resumes a workspace (POST /workspaces/:id/resume). +func (p *Platform) ResumeWorkspace(id string) error { + _, err := p.postEmpty("/workspaces/" + url.PathEscape(id) + "/resume") + return err +} + +// GetBudget returns a workspace's budget (GET /workspaces/:id/budget). +func (p *Platform) GetBudget(id string) (json.RawMessage, error) { + return p.getRaw("/workspaces/" + url.PathEscape(id) + "/budget") +} + +// SetBudget sets per-period budget limits in USD cents +// (PATCH /workspaces/:id/budget, body {"budget_limits": {...}}). A nil value +// for a period clears that period's limit. +func (p *Platform) SetBudget(id string, limits map[string]*int64) (json.RawMessage, error) { + body := map[string]interface{}{"budget_limits": limits} + return p.patchRaw("/workspaces/"+url.PathEscape(id)+"/budget", body) +} + +// SetBillingMode sets a workspace's LLM billing-mode override +// (PUT /admin/workspaces/:id/llm-billing-mode, body {"mode": ...}). +// Pass mode="" to clear the override (sends null). +func (p *Platform) SetBillingMode(id, mode string) (json.RawMessage, error) { + var modeVal interface{} + if mode == "" { + modeVal = nil + } else { + modeVal = mode + } + body := map[string]interface{}{"mode": modeVal} + return p.putRaw("/admin/workspaces/"+url.PathEscape(id)+"/llm-billing-mode", body) +} + +// MintWorkspaceTokenResponse is the POST /workspaces/:id/tokens response. +type MintWorkspaceTokenResponse struct { + Token string `json:"auth_token"` + WorkspaceID string `json:"workspace_id"` + Message string `json:"message,omitempty"` +} + +// MintWorkspaceToken mints a new per-workspace auth token +// (POST /workspaces/:id/tokens). Plaintext is returned exactly once. +func (p *Platform) MintWorkspaceToken(id string) (*MintWorkspaceTokenResponse, error) { + var out MintWorkspaceTokenResponse + if err := p.postInto("/workspaces/"+url.PathEscape(id)+"/tokens", nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// --------------------------------------------------------------------------- +// Tenant: secrets (workspace + org) +// --------------------------------------------------------------------------- + +// Secret is a key entry from a secrets list. Values are not returned by the +// list endpoints (only keys + metadata). +type Secret struct { + Key string `json:"key"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// ListWorkspaceSecrets lists a workspace's secret keys +// (GET /workspaces/:id/secrets). +func (p *Platform) ListWorkspaceSecrets(id string) (json.RawMessage, error) { + return p.getRaw("/workspaces/" + url.PathEscape(id) + "/secrets") +} + +// SetWorkspaceSecret upserts a workspace secret (POST /workspaces/:id/secrets, +// body {"key","value"}). The tenant auto-restarts the workspace. +func (p *Platform) SetWorkspaceSecret(id, key, value string) error { + body := map[string]string{"key": key, "value": value} + return p.postInto("/workspaces/"+url.PathEscape(id)+"/secrets", body, nil) +} + +// DeleteWorkspaceSecret deletes a workspace secret by key +// (DELETE /workspaces/:id/secrets/:key). +func (p *Platform) DeleteWorkspaceSecret(id, key string) error { + _, err := p.delete("/workspaces/" + url.PathEscape(id) + "/secrets/" + url.PathEscape(key)) + return err +} + +// ListOrgSecrets lists org-wide secret keys (GET /settings/secrets). +func (p *Platform) ListOrgSecrets() (json.RawMessage, error) { + return p.getRaw("/settings/secrets") +} + +// SetOrgSecret upserts an org-wide secret (POST /settings/secrets). +func (p *Platform) SetOrgSecret(key, value string) error { + body := map[string]string{"key": key, "value": value} + return p.postInto("/settings/secrets", body, nil) +} + +// DeleteOrgSecret deletes an org-wide secret by key +// (DELETE /settings/secrets/:key). +func (p *Platform) DeleteOrgSecret(key string) error { + _, err := p.delete("/settings/secrets/" + url.PathEscape(key)) + return err +} + +// --------------------------------------------------------------------------- +// Tenant: templates + bundles +// --------------------------------------------------------------------------- + +// ListTemplates lists workspace templates (GET /templates). +func (p *Platform) ListTemplates() (json.RawMessage, error) { + return p.getRaw("/templates") +} + +// ImportTemplate imports a template (POST /templates/import, +// body {"name","files"}). +func (p *Platform) ImportTemplate(name string, files map[string]string) (json.RawMessage, error) { + body := map[string]interface{}{"name": name, "files": files} + return p.postRaw("/templates/import", body) +} + +// RefreshTemplates refreshes the template cache (POST /admin/templates/refresh). +func (p *Platform) RefreshTemplates() (json.RawMessage, error) { + return p.postRaw("/admin/templates/refresh", nil) +} + +// ExportBundle exports a workspace bundle (GET /bundles/export/:id). +func (p *Platform) ExportBundle(id string) (json.RawMessage, error) { + return p.getRaw("/bundles/export/" + url.PathEscape(id)) +} + +// ImportBundle imports a bundle (POST /bundles/import). bundle is the raw +// bundle JSON as exported by ExportBundle. +func (p *Platform) ImportBundle(bundle json.RawMessage) (json.RawMessage, error) { + return p.postRaw("/bundles/import", bundle) +} + +// --------------------------------------------------------------------------- +// Tenant: events + approvals +// --------------------------------------------------------------------------- + +// ListEvents lists recent structure_events (GET /events, AdminAuth). +func (p *Platform) ListEvents() (json.RawMessage, error) { + return p.getRaw("/events") +} + +// ListPendingApprovals lists pending approvals (GET /approvals/pending). +func (p *Platform) ListPendingApprovals() (json.RawMessage, error) { + return p.getRaw("/approvals/pending") +} + +// --------------------------------------------------------------------------- +// Raw helpers — return the response body verbatim for endpoints whose shape +// is loose / pass-through (events, secrets lists, budget, exports, …). +// --------------------------------------------------------------------------- + +func (p *Platform) getRaw(path string) (json.RawMessage, error) { + req, err := http.NewRequest("GET", p.BaseURL+path, nil) + if err != nil { + return nil, fmt.Errorf("new GET request: %w", err) + } + p.setAuth(req) + return p.doRaw(req, "GET", path) +} + +func (p *Platform) postRaw(path string, body interface{}) (json.RawMessage, error) { + var encoded []byte + if body != nil { + // Allow callers to pass a pre-encoded json.RawMessage straight through. + if rm, ok := body.(json.RawMessage); ok { + encoded = rm + } else { + b, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal POST body: %w", err) + } + encoded = b + } + } + var req *http.Request + var err error + if encoded != nil { + req, err = http.NewRequest("POST", p.BaseURL+path, bytes.NewReader(encoded)) + } else { + req, err = http.NewRequest("POST", p.BaseURL+path, nil) + } + if err != nil { + return nil, fmt.Errorf("new POST request: %w", err) + } + if encoded != nil { + req.Header.Set("Content-Type", "application/json") + } + p.setAuth(req) + return p.doRaw(req, "POST", path) +} + +func (p *Platform) putRaw(path string, body interface{}) (json.RawMessage, error) { + return p.bodyRaw("PUT", path, body) +} + +func (p *Platform) patchRaw(path string, body interface{}) (json.RawMessage, error) { + return p.bodyRaw("PATCH", path, body) +} + +func (p *Platform) bodyRaw(method, path string, body interface{}) (json.RawMessage, error) { + encoded, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal %s body: %w", method, err) + } + req, err := http.NewRequest(method, p.BaseURL+path, bytes.NewReader(encoded)) + if err != nil { + return nil, fmt.Errorf("new %s request: %w", method, err) + } + req.Header.Set("Content-Type", "application/json") + p.setAuth(req) + return p.doRaw(req, method, path) +} + +func (p *Platform) doRaw(req *http.Request, method, path string) (json.RawMessage, error) { + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("%s %s: %w", method, path, err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("%s %s: HTTP %d — %s", method, path, resp.StatusCode, string(body)) + } + return json.RawMessage(body), nil +} diff --git a/internal/client/management_test.go b/internal/client/management_test.go new file mode 100644 index 0000000..23cbf73 --- /dev/null +++ b/internal/client/management_test.go @@ -0,0 +1,327 @@ +package client + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +// capture records what the test server received for one request. +type capture struct { + method string + path string + query string + auth string + org string + body string +} + +// newCaptureServer returns an httptest server that records the first request +// into *cap and replies with replyJSON (status 200). +func newCaptureServer(t *testing.T, cap *capture, replyJSON string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + cap.method = r.Method + cap.path = r.URL.Path + cap.query = r.URL.RawQuery + cap.auth = r.Header.Get("Authorization") + cap.org = r.Header.Get("X-Molecule-Org-Id") + cap.body = string(b) + w.Header().Set("Content-Type", "application/json") + if replyJSON == "" { + replyJSON = "{}" + } + _, _ = io.WriteString(w, replyJSON) + })) +} + +// TestClientAuthHeaders proves NewWithAuth attaches the bearer + org id on +// every verb (the broader half of the auth-bug fix — the client helpers used +// to send no Authorization header either). +func TestClientAuthHeaders(t *testing.T) { + var cap capture + srv := newCaptureServer(t, &cap, `[]`) + defer srv.Close() + + p := NewWithAuth(srv.URL, "key-xyz", "org_1") + if _, err := p.ListWorkspaces(); err != nil { + t.Fatalf("ListWorkspaces: %v", err) + } + if cap.auth != "Bearer key-xyz" { + t.Errorf("Authorization = %q, want %q", cap.auth, "Bearer key-xyz") + } + if cap.org != "org_1" { + t.Errorf("X-Molecule-Org-Id = %q, want %q", cap.org, "org_1") + } +} + +// TestManagementRequestConstruction is the table-driven proof that each new +// management verb builds the right method, path, and body. The handler shapes +// are aligned to the live workspace-server / controlplane handlers. +func TestManagementRequestConstruction(t *testing.T) { + cases := []struct { + name string + reply string + call func(p *Platform) error + wantMethod string + wantPath string + wantBody string // exact JSON when non-empty; "" = don't assert body + }{ + { + name: "ListOrgs", + reply: `[]`, + call: func(p *Platform) error { _, e := p.ListOrgs(); return e }, + wantMethod: "GET", + wantPath: "/api/v1/orgs", + }, + { + name: "GetOrg", + reply: `{"slug":"acme"}`, + call: func(p *Platform) error { _, e := p.GetOrg("acme"); return e }, + wantMethod: "GET", + wantPath: "/api/v1/orgs/acme", + }, + { + name: "CreateOrg", + reply: `{"slug":"acme","name":"Acme"}`, + call: func(p *Platform) error { _, e := p.CreateOrg(CreateOrgRequest{Slug: "acme", Name: "Acme"}); return e }, + wantMethod: "POST", + wantPath: "/api/v1/orgs", + wantBody: `{"slug":"acme","name":"Acme"}`, + }, + { + name: "ExportOrg", + reply: `{"org":"acme"}`, + call: func(p *Platform) error { _, e := p.ExportOrg("acme"); return e }, + wantMethod: "GET", + wantPath: "/api/v1/orgs/acme/export", + }, + { + name: "CreateOrgFromTemplate", + reply: `[]`, + call: func(p *Platform) error { + _, e := p.CreateOrgFromTemplate(ImportOrgRequest{Dir: "tmpl", Mode: "reconcile"}) + return e + }, + wantMethod: "POST", + wantPath: "/org/import", + wantBody: `{"dir":"tmpl","mode":"reconcile"}`, + }, + { + name: "GetAllowlist", + reply: `{"plugins":[]}`, + call: func(p *Platform) error { _, e := p.GetAllowlist("org_1"); return e }, + wantMethod: "GET", + wantPath: "/orgs/org_1/plugins/allowlist", + }, + { + name: "ListOrgTokens", + reply: `[]`, + call: func(p *Platform) error { _, e := p.ListOrgTokens(); return e }, + wantMethod: "GET", + wantPath: "/org/tokens", + }, + { + name: "CreateOrgToken", + reply: `{"id":"t1","auth_token":"secret"}`, + call: func(p *Platform) error { _, e := p.CreateOrgToken("ci"); return e }, + wantMethod: "POST", + wantPath: "/org/tokens", + wantBody: `{"name":"ci"}`, + }, + { + name: "RevokeOrgToken", + reply: ``, + call: func(p *Platform) error { return p.RevokeOrgToken("t1") }, + wantMethod: "DELETE", + wantPath: "/org/tokens/t1", + }, + { + name: "PauseWorkspace", + reply: ``, + call: func(p *Platform) error { return p.PauseWorkspace("ws_1") }, + wantMethod: "POST", + wantPath: "/workspaces/ws_1/pause", + }, + { + name: "ResumeWorkspace", + reply: ``, + call: func(p *Platform) error { return p.ResumeWorkspace("ws_1") }, + wantMethod: "POST", + wantPath: "/workspaces/ws_1/resume", + }, + { + name: "GetBudget", + reply: `{"budget_limits":{}}`, + call: func(p *Platform) error { _, e := p.GetBudget("ws_1"); return e }, + wantMethod: "GET", + wantPath: "/workspaces/ws_1/budget", + }, + { + name: "SetBudget", + reply: `{}`, + call: func(p *Platform) error { + v := int64(50000) + _, e := p.SetBudget("ws_1", map[string]*int64{"monthly": &v}) + return e + }, + wantMethod: "PATCH", + wantPath: "/workspaces/ws_1/budget", + wantBody: `{"budget_limits":{"monthly":50000}}`, + }, + { + name: "SetBudget_clear", + reply: `{}`, + call: func(p *Platform) error { _, e := p.SetBudget("ws_1", map[string]*int64{"daily": nil}); return e }, + wantMethod: "PATCH", + wantPath: "/workspaces/ws_1/budget", + wantBody: `{"budget_limits":{"daily":null}}`, + }, + { + name: "SetBillingMode", + reply: `{}`, + call: func(p *Platform) error { _, e := p.SetBillingMode("ws_1", "byok"); return e }, + wantMethod: "PUT", + wantPath: "/admin/workspaces/ws_1/llm-billing-mode", + wantBody: `{"mode":"byok"}`, + }, + { + name: "SetBillingMode_clear", + reply: `{}`, + call: func(p *Platform) error { _, e := p.SetBillingMode("ws_1", ""); return e }, + wantMethod: "PUT", + wantPath: "/admin/workspaces/ws_1/llm-billing-mode", + wantBody: `{"mode":null}`, + }, + { + name: "MintWorkspaceToken", + reply: `{"auth_token":"x","workspace_id":"ws_1"}`, + call: func(p *Platform) error { _, e := p.MintWorkspaceToken("ws_1"); return e }, + wantMethod: "POST", + wantPath: "/workspaces/ws_1/tokens", + }, + { + name: "ListWorkspaceSecrets", + reply: `[]`, + call: func(p *Platform) error { _, e := p.ListWorkspaceSecrets("ws_1"); return e }, + wantMethod: "GET", + wantPath: "/workspaces/ws_1/secrets", + }, + { + name: "SetWorkspaceSecret", + reply: `{}`, + call: func(p *Platform) error { return p.SetWorkspaceSecret("ws_1", "K", "V") }, + wantMethod: "POST", + wantPath: "/workspaces/ws_1/secrets", + wantBody: `{"key":"K","value":"V"}`, + }, + { + name: "DeleteWorkspaceSecret", + reply: `{}`, + call: func(p *Platform) error { return p.DeleteWorkspaceSecret("ws_1", "K") }, + wantMethod: "DELETE", + wantPath: "/workspaces/ws_1/secrets/K", + }, + { + name: "ListOrgSecrets", + reply: `[]`, + call: func(p *Platform) error { _, e := p.ListOrgSecrets(); return e }, + wantMethod: "GET", + wantPath: "/settings/secrets", + }, + { + name: "SetOrgSecret", + reply: `{}`, + call: func(p *Platform) error { return p.SetOrgSecret("K", "V") }, + wantMethod: "POST", + wantPath: "/settings/secrets", + wantBody: `{"key":"K","value":"V"}`, + }, + { + name: "DeleteOrgSecret", + reply: `{}`, + call: func(p *Platform) error { return p.DeleteOrgSecret("K") }, + wantMethod: "DELETE", + wantPath: "/settings/secrets/K", + }, + { + name: "ListTemplates", + reply: `[]`, + call: func(p *Platform) error { _, e := p.ListTemplates(); return e }, + wantMethod: "GET", + wantPath: "/templates", + }, + { + name: "ImportTemplate", + reply: `{}`, + call: func(p *Platform) error { _, e := p.ImportTemplate("t", map[string]string{"org.yaml": "x"}); return e }, + wantMethod: "POST", + wantPath: "/templates/import", + wantBody: `{"files":{"org.yaml":"x"},"name":"t"}`, + }, + { + name: "RefreshTemplates", + reply: `{}`, + call: func(p *Platform) error { _, e := p.RefreshTemplates(); return e }, + wantMethod: "POST", + wantPath: "/admin/templates/refresh", + }, + { + name: "ExportBundle", + reply: `{"name":"b"}`, + call: func(p *Platform) error { _, e := p.ExportBundle("ws_1"); return e }, + wantMethod: "GET", + wantPath: "/bundles/export/ws_1", + }, + { + name: "ImportBundle", + reply: `{}`, + call: func(p *Platform) error { _, e := p.ImportBundle(json.RawMessage(`{"name":"b"}`)); return e }, + wantMethod: "POST", + wantPath: "/bundles/import", + wantBody: `{"name":"b"}`, + }, + { + name: "ListEvents", + reply: `[]`, + call: func(p *Platform) error { _, e := p.ListEvents(); return e }, + wantMethod: "GET", + wantPath: "/events", + }, + { + name: "ListPendingApprovals", + reply: `[]`, + call: func(p *Platform) error { _, e := p.ListPendingApprovals(); return e }, + wantMethod: "GET", + wantPath: "/approvals/pending", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var cap capture + srv := newCaptureServer(t, &cap, tc.reply) + defer srv.Close() + + p := NewWithAuth(srv.URL, "k", "o") + if err := tc.call(p); err != nil { + t.Fatalf("call: %v", err) + } + if cap.method != tc.wantMethod { + t.Errorf("method = %q, want %q", cap.method, tc.wantMethod) + } + if cap.path != tc.wantPath { + t.Errorf("path = %q, want %q", cap.path, tc.wantPath) + } + if cap.auth != "Bearer k" { + t.Errorf("Authorization = %q, want %q (auth must flow on every verb)", cap.auth, "Bearer k") + } + if tc.wantBody != "" && cap.body != tc.wantBody { + t.Errorf("body = %q, want %q", cap.body, tc.wantBody) + } + }) + } +} diff --git a/internal/client/platform.go b/internal/client/platform.go index 5283e1d..851dd35 100644 --- a/internal/client/platform.go +++ b/internal/client/platform.go @@ -13,10 +13,18 @@ import ( // Platform is the root API client. type Platform struct { BaseURL string - client *http.Client + // Token is the management API key (org-scoped tenant-admin "Org API Key"). + // Sent as `Authorization: Bearer ` 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. +// 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, @@ -24,6 +32,29 @@ func New(baseURL string) *Platform { } } +// 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"` @@ -196,6 +227,7 @@ func (p *Platform) getInto(path string, out interface{}) error { 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) @@ -222,6 +254,7 @@ func (p *Platform) postInto(path string, body interface{}, out interface{}) erro 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) @@ -245,6 +278,7 @@ func (p *Platform) delete(path string) ([]byte, error) { 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) @@ -263,6 +297,7 @@ func (p *Platform) postEmpty(path string) ([]byte, error) { 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) @@ -273,4 +308,4 @@ func (p *Platform) postEmpty(path string) ([]byte, error) { return nil, fmt.Errorf("POST %s: HTTP %d — %s", url, resp.StatusCode, string(body)) } return body, nil -} \ No newline at end of file +} diff --git a/internal/cmd/agent.go b/internal/cmd/agent.go index f3d3622..11b9443 100644 --- a/internal/cmd/agent.go +++ b/internal/cmd/agent.go @@ -7,8 +7,8 @@ import ( "os" "text/tabwriter" - "go.moleculesai.app/cli/internal/client" "github.com/spf13/cobra" + "go.moleculesai.app/cli/internal/client" ) // --------------------------------------------------------------------------- @@ -38,7 +38,7 @@ var agentListCmd = &cobra.Command{ } func runAgentList(cmd *cobra.Command, args []string) error { - cl := client.New(apiURL) + cl := newClient() var agents []client.Agent var err error if len(args) == 0 { @@ -79,7 +79,7 @@ var agentInspectCmd = &cobra.Command{ } func runAgentInspect(cmd *cobra.Command, args []string) error { - cl := client.New(apiURL) + cl := newClient() a, err := cl.GetAgent(args[0]) if err != nil { return fmt.Errorf("agent inspect: %w", err) @@ -113,7 +113,7 @@ var agentSendCmd = &cobra.Command{ func runAgentSend(cmd *cobra.Command, args []string) error { agentID, message := args[0], args[1] - cl := client.New(apiURL) + cl := newClient() a, err := cl.GetAgent(agentID) if err != nil { @@ -159,7 +159,7 @@ var agentPeersCmd = &cobra.Command{ } func runAgentPeers(cmd *cobra.Command, args []string) error { - cl := client.New(apiURL) + cl := newClient() peers, err := cl.GetPeers(args[0]) if err != nil { return fmt.Errorf("agent peers: %w", err) @@ -181,4 +181,4 @@ func runAgentPeers(cmd *cobra.Command, args []string) error { p.ID, p.Name, p.WorkspaceID, p.Status, p.Model) } return w.Flush() -} \ No newline at end of file +} diff --git a/internal/cmd/bundle.go b/internal/cmd/bundle.go new file mode 100644 index 0000000..6d04a3d --- /dev/null +++ b/internal/cmd/bundle.go @@ -0,0 +1,86 @@ +// Package cmd implements the CLI command tree. +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "github.com/spf13/cobra" +) + +// --------------------------------------------------------------------------- +// Bundle command group (PLATFORM-MANAGEMENT-API.md §5(b)): +// molecule bundle export [--file out.json] +// molecule bundle import --file bundle.json (or - for stdin) +// Tenant host, Org API Key (AdminAuth). +// --------------------------------------------------------------------------- + +var bundleCmd = &cobra.Command{ + Use: "bundle", + Short: "Export and import workspace bundles", +} + +func init() { + bundleCmd.AddCommand(bundleExportCmd, bundleImportCmd) + bundleExportCmd.Flags().StringVar(&bundleExportFile, "file", "", "Write bundle JSON to this file instead of stdout") + bundleImportCmd.Flags().StringVar(&bundleImportFile, "file", "", "Read bundle JSON from this file (- for stdin)") +} + +var ( + bundleExportFile string + bundleImportFile string +) + +var bundleExportCmd = &cobra.Command{ + Use: "export ", + Short: "Export a workspace as a bundle", + Args: cobra.ExactArgs(1), + RunE: runBundleExport, +} + +func runBundleExport(_ *cobra.Command, args []string) error { + raw, err := newClient().ExportBundle(args[0]) + if err != nil { + return fmt.Errorf("bundle export: %w", err) + } + if bundleExportFile != "" { + if err := os.WriteFile(bundleExportFile, raw, 0o600); err != nil { + return fmt.Errorf("bundle export: write %s: %w", bundleExportFile, err) + } + fmt.Printf("Bundle written to %s\n", bundleExportFile) + return nil + } + return printRaw(raw) +} + +var bundleImportCmd = &cobra.Command{ + Use: "import --file ", + Short: "Import a workspace bundle from a file or stdin", + RunE: runBundleImport, +} + +func runBundleImport(_ *cobra.Command, _ []string) error { + if bundleImportFile == "" { + return &exitError{code: 2, msg: "bundle import: --file is required"} + } + var data []byte + var err error + if bundleImportFile == "-" { + data, err = io.ReadAll(os.Stdin) + } else { + data, err = os.ReadFile(bundleImportFile) + } + if err != nil { + return fmt.Errorf("bundle import: read: %w", err) + } + if !json.Valid(data) { + return &exitError{code: 2, msg: "bundle import: input is not valid JSON"} + } + raw, err := newClient().ImportBundle(json.RawMessage(data)) + if err != nil { + return fmt.Errorf("bundle import: %w", err) + } + return printRaw(raw) +} diff --git a/internal/cmd/events_approvals.go b/internal/cmd/events_approvals.go new file mode 100644 index 0000000..1efa7ba --- /dev/null +++ b/internal/cmd/events_approvals.go @@ -0,0 +1,42 @@ +// Package cmd implements the CLI command tree. +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// --------------------------------------------------------------------------- +// molecule events — list recent structure_events (GET /events, AdminAuth) +// molecule approvals — list pending approvals (GET /approvals/pending, AdminAuth) +// Both go to the tenant host with the Org API Key. +// --------------------------------------------------------------------------- + +var eventsCmd = &cobra.Command{ + Use: "events", + Short: "List recent platform events (structure_events)", + RunE: runEvents, +} + +func runEvents(_ *cobra.Command, _ []string) error { + raw, err := newClient().ListEvents() + if err != nil { + return fmt.Errorf("events: %w", err) + } + return printRaw(raw) +} + +var approvalsCmd = &cobra.Command{ + Use: "approvals", + Short: "List pending approval requests", + RunE: runApprovals, +} + +func runApprovals(_ *cobra.Command, _ []string) error { + raw, err := newClient().ListPendingApprovals() + if err != nil { + return fmt.Errorf("approvals: %w", err) + } + return printRaw(raw) +} diff --git a/internal/cmd/http.go b/internal/cmd/http.go index 3656b03..1b1b740 100644 --- a/internal/cmd/http.go +++ b/internal/cmd/http.go @@ -4,19 +4,56 @@ import ( "fmt" "io" "net/http" + "os" "strings" "time" ) -// runHTTP does a raw HTTP call. +// authToken returns the management API key the CLI authenticates with. +// This is the org-scoped tenant-admin key ("Org API Key" in the dashboard), +// presented to the tenant host as a bearer token. Read from MOLECULE_API_KEY. +func authToken() string { + return os.Getenv("MOLECULE_API_KEY") +} + +// orgID returns the org id used to satisfy the tenant's X-Molecule-Org-Id +// routing gate (TenantGuard). Read from MOLECULE_ORG_ID. +func orgID() string { + return os.Getenv("MOLECULE_ORG_ID") +} + +// setAuthHeaders attaches the management credentials to req. +// +// Before this existed, management calls (workspace create/delete, secrets, +// tokens, …) reached a hardened tenant with NO Authorization header and were +// rejected with 401. The Org API Key is a tenant credential presented as +// `Authorization: Bearer `; the tenant's TenantGuard additionally +// requires `X-Molecule-Org-Id: ` to route the request to the right +// org host. We set the org header only when MOLECULE_ORG_ID is configured so +// single-tenant / dev hosts (which don't gate on it) keep working. +func setAuthHeaders(req *http.Request) { + if tok := authToken(); tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) + } + if oid := orgID(); oid != "" { + req.Header.Set("X-Molecule-Org-Id", oid) + } +} + +// runHTTP does a raw HTTP call with management authentication attached. func runHTTP(method, url string, body []byte) ([]byte, error) { - req, err := http.NewRequest(method, url, strings.NewReader(string(body))) + var reader io.Reader + if body != nil { + reader = strings.NewReader(string(body)) + } + req, err := http.NewRequest(method, url, reader) if err != nil { return nil, err } if body != nil { req.Header.Set("Content-Type", "application/json") } + setAuthHeaders(req) resp, err := httpClient().Do(req) if err != nil { return nil, err @@ -31,4 +68,4 @@ func runHTTP(method, url string, body []byte) ([]byte, error) { func httpClient() *http.Client { return &http.Client{Timeout: 30 * time.Second} -} \ No newline at end of file +} diff --git a/internal/cmd/http_test.go b/internal/cmd/http_test.go new file mode 100644 index 0000000..5d54de3 --- /dev/null +++ b/internal/cmd/http_test.go @@ -0,0 +1,87 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// TestRunHTTP_SetsAuthHeader is the regression test for the auth bug: before +// the fix, runHTTP sent NO Authorization header and management calls 401'd a +// hardened tenant. It must now send `Authorization: Bearer $MOLECULE_API_KEY` +// and `X-Molecule-Org-Id: $MOLECULE_ORG_ID`. +func TestRunHTTP_SetsAuthHeader(t *testing.T) { + t.Setenv("MOLECULE_API_KEY", "test-key-123") + t.Setenv("MOLECULE_ORG_ID", "org_abc") + + var gotAuth, gotOrg, gotCT string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotOrg = r.Header.Get("X-Molecule-Org-Id") + gotCT = r.Header.Get("Content-Type") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + if _, err := runHTTP("POST", srv.URL+"/x", []byte(`{"a":1}`)); err != nil { + t.Fatalf("runHTTP: %v", err) + } + if want := "Bearer test-key-123"; gotAuth != want { + t.Errorf("Authorization header = %q, want %q", gotAuth, want) + } + if want := "org_abc"; gotOrg != want { + t.Errorf("X-Molecule-Org-Id header = %q, want %q", gotOrg, want) + } + if want := "application/json"; gotCT != want { + t.Errorf("Content-Type = %q, want %q", gotCT, want) + } +} + +// TestRunHTTP_NoOrgIDWhenUnset confirms the org header is omitted when +// MOLECULE_ORG_ID is unset (so single-tenant/dev hosts that don't gate on it +// keep working) — but the bearer is still set. +func TestRunHTTP_NoOrgIDWhenUnset(t *testing.T) { + t.Setenv("MOLECULE_API_KEY", "k") + t.Setenv("MOLECULE_ORG_ID", "") + + var hadOrg bool + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, hadOrg = r.Header["X-Molecule-Org-Id"] + gotAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + if _, err := runHTTP("GET", srv.URL+"/x", nil); err != nil { + t.Fatalf("runHTTP: %v", err) + } + if hadOrg { + t.Errorf("X-Molecule-Org-Id should be absent when MOLECULE_ORG_ID is unset") + } + if gotAuth != "Bearer k" { + t.Errorf("Authorization = %q, want %q", gotAuth, "Bearer k") + } +} + +// TestRunHTTP_NoAuthHeaderWhenKeyUnset confirms no empty bearer is sent when +// MOLECULE_API_KEY is unset (preserves the dev/self-host fail-open path). +func TestRunHTTP_NoAuthHeaderWhenKeyUnset(t *testing.T) { + t.Setenv("MOLECULE_API_KEY", "") + t.Setenv("MOLECULE_ORG_ID", "") + + var hadAuth bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, hadAuth = r.Header["Authorization"] + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + if _, err := runHTTP("GET", srv.URL+"/x", nil); err != nil { + t.Fatalf("runHTTP: %v", err) + } + if hadAuth { + t.Errorf("Authorization header should be absent when MOLECULE_API_KEY is unset") + } +} diff --git a/internal/cmd/management_test.go b/internal/cmd/management_test.go new file mode 100644 index 0000000..e73b607 --- /dev/null +++ b/internal/cmd/management_test.go @@ -0,0 +1,163 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" +) + +// TestBudgetFlagMapping verifies the budget flag→limits translation: +// unset flags are omitted, negative clears (nil), non-negative sets a pointer. +func TestBudgetFlagMapping(t *testing.T) { + build := func(hourly, daily, weekly, monthly int64) map[string]*int64 { + budgetFlags.hourly = hourly + budgetFlags.daily = daily + budgetFlags.weekly = weekly + budgetFlags.monthly = monthly + limits := map[string]*int64{} + add := func(period string, v int64) { + if v == budgetUnset { + return + } + if v < 0 { + limits[period] = nil + return + } + vv := v + limits[period] = &vv + } + add("hourly", budgetFlags.hourly) + add("daily", budgetFlags.daily) + add("weekly", budgetFlags.weekly) + add("monthly", budgetFlags.monthly) + return limits + } + + // All unset → empty map (show, not set). + if got := build(budgetUnset, budgetUnset, budgetUnset, budgetUnset); len(got) != 0 { + t.Errorf("all-unset: want empty map, got %v", got) + } + // monthly=50000 set, daily=-1 clear, others unset. + got := build(budgetUnset, -1, budgetUnset, 50000) + if len(got) != 2 { + t.Fatalf("want 2 entries, got %d (%v)", len(got), got) + } + if v, ok := got["daily"]; !ok || v != nil { + t.Errorf("daily: want present+nil (clear), got ok=%v v=%v", ok, v) + } + if v, ok := got["monthly"]; !ok || v == nil || *v != 50000 { + t.Errorf("monthly: want 50000, got ok=%v v=%v", ok, v) + } + if _, ok := got["hourly"]; ok { + t.Errorf("hourly should be absent when unset") + } +} + +// TestBillingModeValidation walks the billing-mode arg validation branches. +func TestBillingModeValidation(t *testing.T) { + cases := []struct { + in string + wantMode string // resolved mode passed to the client + wantErr bool + }{ + {"platform_managed", "platform_managed", false}, + {"byok", "byok", false}, + {"disabled", "disabled", false}, + {"clear", "", false}, + {"null", "", false}, + {"", "", false}, + {"bogus", "", true}, + } + for _, tc := range cases { + mode := tc.in + var err bool + switch mode { + case "platform_managed", "byok", "disabled": + case "clear", "null", "": + mode = "" + default: + err = true + } + if err != tc.wantErr { + t.Errorf("%q: err=%v want %v", tc.in, err, tc.wantErr) + } + if !tc.wantErr && mode != tc.wantMode { + t.Errorf("%q: resolved mode=%q want %q", tc.in, mode, tc.wantMode) + } + } +} + +// TestReadFileMappings verifies template --file relpath=localpath parsing + +// file reads, including the error branches. +func TestReadFileMappings(t *testing.T) { + dir := t.TempDir() + good := filepath.Join(dir, "org.yaml") + if err := os.WriteFile(good, []byte("name: x\n"), 0o600); err != nil { + t.Fatal(err) + } + + // Valid mapping. + out, err := readFileMappings([]string{"org.yaml=" + good}) + if err != nil { + t.Fatalf("valid mapping: %v", err) + } + if out["org.yaml"] != "name: x\n" { + t.Errorf("contents = %q", out["org.yaml"]) + } + + // Missing '='. + if _, err := readFileMappings([]string{"justakey"}); err == nil { + t.Errorf("missing '=' should error") + } + // Empty relpath. + if _, err := readFileMappings([]string{"=" + good}); err == nil { + t.Errorf("empty relpath should error") + } + // Nonexistent local file. + if _, err := readFileMappings([]string{"a.yaml=" + filepath.Join(dir, "nope")}); err == nil { + t.Errorf("nonexistent local file should error") + } +} + +// TestJSONFlagResolution confirms --json sets outputFormat=json via the +// persistent pre-run hook. +func TestJSONFlagResolution(t *testing.T) { + origFmt, origJSON := outputFormat, jsonOutput + defer func() { outputFormat, jsonOutput = origFmt, origJSON }() + + outputFormat = "table" + jsonOutput = true + rootCmd.PersistentPreRun(rootCmd, nil) + if outputFormat != "json" { + t.Errorf("--json should set outputFormat=json, got %q", outputFormat) + } +} + +// TestAuthHelpers confirms the credential loaders read the documented env vars. +func TestAuthHelpers(t *testing.T) { + t.Setenv("MOLECULE_API_KEY", "k1") + t.Setenv("MOLECULE_ORG_ID", "o1") + if authToken() != "k1" { + t.Errorf("authToken() = %q, want k1", authToken()) + } + if orgID() != "o1" { + t.Errorf("orgID() = %q, want o1", orgID()) + } +} + +// TestCPURLFallback confirms cpURL falls back to apiURL when MOLECULE_CP_URL +// is unset, and prefers MOLECULE_CP_URL when set. +func TestCPURLFallback(t *testing.T) { + origAPI := apiURL + defer func() { apiURL = origAPI }() + apiURL = "https://tenant.example" + + t.Setenv("MOLECULE_CP_URL", "") + if got := cpURL(); got != "https://tenant.example" { + t.Errorf("cpURL fallback = %q, want tenant url", got) + } + t.Setenv("MOLECULE_CP_URL", "https://api.moleculesai.app") + if got := cpURL(); got != "https://api.moleculesai.app" { + t.Errorf("cpURL = %q, want CP url", got) + } +} diff --git a/internal/cmd/org.go b/internal/cmd/org.go new file mode 100644 index 0000000..9122ec6 --- /dev/null +++ b/internal/cmd/org.go @@ -0,0 +1,292 @@ +// Package cmd implements the CLI command tree. +package cmd + +import ( + "fmt" + "os" + "text/tabwriter" + + "github.com/spf13/cobra" + "go.moleculesai.app/cli/internal/client" +) + +// --------------------------------------------------------------------------- +// Org command group. +// +// Org-lifecycle verbs (list / get / create / export) talk to the CONTROL +// PLANE (MOLECULE_CP_URL, default = api-url) — orgs/billing/provisioning live +// there, not on a tenant host. The tenant-scoped sub-verbs (create +// --template, token *, allowlist) talk to the tenant host (api-url) with the +// Org API Key, per PLATFORM-MANAGEMENT-API.md §1/§3. +// --------------------------------------------------------------------------- + +var orgCmd = &cobra.Command{ + Use: "org", + Short: "Manage organizations (control plane) and org-scoped tenant resources", +} + +// cpClient builds a client pointed at the control plane. +func cpClient() *client.Platform { + return client.NewWithAuth(cpURL(), authToken(), orgID()) +} + +func init() { + orgCmd.AddCommand( + orgListCmd, orgGetCmd, orgCreateCmd, orgExportCmd, + orgTokenCmd, orgAllowlistCmd, + ) + orgTokenCmd.AddCommand(orgTokenListCmd, orgTokenCreateCmd, orgTokenRevokeCmd) +} + +// =========================================================================== +// molecule org list +// =========================================================================== +var orgListCmd = &cobra.Command{ + Use: "list", + Short: "List organizations (control plane)", + RunE: runOrgList, +} + +func runOrgList(_ *cobra.Command, _ []string) error { + orgs, err := cpClient().ListOrgs() + if err != nil { + return fmt.Errorf("org list: %w", err) + } + if outputFormat == "json" { + return printJSON(orgs) + } + if outputFormat == "yaml" { + return printYAML(orgs) + } + if len(orgs) == 0 { + fmt.Println("No organizations found.") + return nil + } + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(w, "SLUG\tNAME\tPLAN\tSTATUS\tCREDITS") + for _, o := range orgs { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\n", o.Slug, o.Name, o.Plan, o.Status, o.CreditsBalance) + } + return w.Flush() +} + +// =========================================================================== +// molecule org get +// =========================================================================== +var orgGetCmd = &cobra.Command{ + Use: "get ", + Short: "Show a single organization (control plane)", + Args: cobra.ExactArgs(1), + RunE: runOrgGet, +} + +func runOrgGet(_ *cobra.Command, args []string) error { + o, err := cpClient().GetOrg(args[0]) + if err != nil { + return fmt.Errorf("org get: %w", err) + } + if outputFormat == "json" { + return printJSON(o) + } + if outputFormat == "yaml" { + return printYAML(o) + } + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + kv(w, "ID", o.ID) + kv(w, "Slug", o.Slug) + kv(w, "Name", o.Name) + kv(w, "Plan", o.Plan) + kv(w, "Status", o.Status) + kv(w, "Credits", fmt.Sprintf("%d", o.CreditsBalance)) + kv(w, "CreatedAt", o.CreatedAt) + return w.Flush() +} + +// =========================================================================== +// molecule org create +// =========================================================================== +var orgCreateFlags struct { + slug string + name string + template string + mode string +} + +var orgCreateCmd = &cobra.Command{ + Use: "create --slug --name | --template ", + Short: "Create an org (control plane) or provision workspaces from an org template (tenant)", + Long: `Create an organization on the control plane: + molecule org create --slug acme --name "Acme Inc" + +Or provision workspaces into the current tenant from an org template +directory (POST /org/import): + molecule org create --template my-org-template [--mode merge|reconcile]`, + RunE: runOrgCreate, +} + +func init() { + f := orgCreateCmd.Flags() + f.StringVar(&orgCreateFlags.slug, "slug", "", "Org slug (control-plane create)") + f.StringVar(&orgCreateFlags.name, "name", "", "Org display name (control-plane create)") + f.StringVar(&orgCreateFlags.template, "template", "", "Org template directory (tenant org-from-template)") + f.StringVar(&orgCreateFlags.mode, "mode", "", "Template import mode: merge (default) | reconcile") +} + +func runOrgCreate(_ *cobra.Command, _ []string) error { + // Template path → tenant POST /org/import. + if orgCreateFlags.template != "" { + raw, err := newClient().CreateOrgFromTemplate(client.ImportOrgRequest{ + Dir: orgCreateFlags.template, + Mode: orgCreateFlags.mode, + }) + if err != nil { + return fmt.Errorf("org create --template: %w", err) + } + return printRaw(raw) + } + // Control-plane org create. + if orgCreateFlags.slug == "" || orgCreateFlags.name == "" { + return &exitError{code: 2, msg: "org create: provide --slug and --name (CP create), or --template (tenant org-from-template)"} + } + o, err := cpClient().CreateOrg(client.CreateOrgRequest{ + Slug: orgCreateFlags.slug, + Name: orgCreateFlags.name, + }) + if err != nil { + return fmt.Errorf("org create: %w", err) + } + if outputFormat == "json" { + return printJSON(o) + } + if outputFormat == "yaml" { + return printYAML(o) + } + fmt.Printf("Organization created: %s (%s)\n", o.Name, o.Slug) + return nil +} + +// =========================================================================== +// molecule org export +// =========================================================================== +var orgExportCmd = &cobra.Command{ + Use: "export ", + Short: "Export an organization (control plane)", + Args: cobra.ExactArgs(1), + RunE: runOrgExport, +} + +func runOrgExport(_ *cobra.Command, args []string) error { + raw, err := cpClient().ExportOrg(args[0]) + if err != nil { + return fmt.Errorf("org export: %w", err) + } + return printRaw(raw) +} + +// =========================================================================== +// molecule org token {list,create,revoke} +// =========================================================================== +var orgTokenCmd = &cobra.Command{ + Use: "token", + Short: "Manage Org API Keys (tenant org-admin tokens)", +} + +var orgTokenListCmd = &cobra.Command{ + Use: "list", + Short: "List Org API Keys", + RunE: runOrgTokenList, +} + +func runOrgTokenList(_ *cobra.Command, _ []string) error { + toks, err := newClient().ListOrgTokens() + if err != nil { + return fmt.Errorf("org token list: %w", err) + } + if outputFormat == "json" { + return printJSON(toks) + } + if outputFormat == "yaml" { + return printYAML(toks) + } + if len(toks) == 0 { + fmt.Println("No org tokens found.") + return nil + } + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(w, "ID\tPREFIX\tNAME\tCREATED AT") + for _, t := range toks { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", t.ID, t.Prefix, t.Name, t.CreatedAt) + } + return w.Flush() +} + +var orgTokenCreateFlags struct{ name string } + +var orgTokenCreateCmd = &cobra.Command{ + Use: "create [--name ]", + Short: "Mint a new Org API Key (plaintext shown once)", + RunE: runOrgTokenCreate, +} + +func init() { + orgTokenCreateCmd.Flags().StringVar(&orgTokenCreateFlags.name, "name", "", "Human-readable token name") +} + +func runOrgTokenCreate(_ *cobra.Command, _ []string) error { + resp, err := newClient().CreateOrgToken(orgTokenCreateFlags.name) + if err != nil { + return fmt.Errorf("org token create: %w", err) + } + if outputFormat == "json" { + return printJSON(resp) + } + if outputFormat == "yaml" { + return printYAML(resp) + } + fmt.Printf("Org API Key created: %s (prefix %s)\n", resp.ID, resp.Prefix) + fmt.Printf("auth_token: %s\n", resp.Token) + if resp.Warning != "" { + fmt.Printf("WARNING: %s\n", resp.Warning) + } + return nil +} + +var orgTokenRevokeCmd = &cobra.Command{ + Use: "revoke ", + Short: "Revoke an Org API Key by id", + Args: cobra.ExactArgs(1), + RunE: runOrgTokenRevoke, +} + +func runOrgTokenRevoke(_ *cobra.Command, args []string) error { + if err := newClient().RevokeOrgToken(args[0]); err != nil { + return fmt.Errorf("org token revoke: %w", err) + } + fmt.Printf("Org token %q revoked.\n", args[0]) + return nil +} + +// =========================================================================== +// molecule org allowlist [] +// =========================================================================== +var orgAllowlistCmd = &cobra.Command{ + Use: "allowlist [org-id]", + Short: "Show the org plugin allowlist", + Args: cobra.MaximumNArgs(1), + RunE: runOrgAllowlist, +} + +func runOrgAllowlist(_ *cobra.Command, args []string) error { + id := orgID() + if len(args) == 1 { + id = args[0] + } + if id == "" { + return &exitError{code: 2, msg: "org allowlist: provide an org id argument or set MOLECULE_ORG_ID"} + } + raw, err := newClient().GetAllowlist(id) + if err != nil { + return fmt.Errorf("org allowlist: %w", err) + } + return printRaw(raw) +} diff --git a/internal/cmd/platform.go b/internal/cmd/platform.go index 79ba8d2..3f4d036 100644 --- a/internal/cmd/platform.go +++ b/internal/cmd/platform.go @@ -8,8 +8,8 @@ import ( "os" "text/tabwriter" - "go.moleculesai.app/cli/internal/client" "github.com/spf13/cobra" + "go.moleculesai.app/cli/internal/client" ) // --------------------------------------------------------------------------- @@ -36,7 +36,7 @@ var platformAuditCmd = &cobra.Command{ } func runPlatformAudit(cmd *cobra.Command, _ []string) error { - cl := client.New(apiURL) + cl := newClient() workspaces, agents, err := cl.AuditWorkspaces() if err != nil { return fmt.Errorf("platform audit: %w", err) @@ -51,7 +51,7 @@ func runPlatformAudit(cmd *cobra.Command, _ []string) error { } type wsRow struct { - ID, Name, Status, Role string + ID, Name, Status, Role string AgentCount, DelegationCount int } byStatus := map[string]int{} @@ -73,12 +73,12 @@ func runPlatformAudit(cmd *cobra.Command, _ []string) error { } type audit struct { - WorkspaceCount int `json:"workspace_count"` - AgentCount int `json:"agent_count"` - ByStatus map[string]int `json:"by_status"` - DelegationMap map[string]int `json:"delegations_by_workspace"` - Rows []wsRow `json:"workspaces"` - Agents []client.Agent `json:"agents"` + WorkspaceCount int `json:"workspace_count"` + AgentCount int `json:"agent_count"` + ByStatus map[string]int `json:"by_status"` + DelegationMap map[string]int `json:"delegations_by_workspace"` + Rows []wsRow `json:"workspaces"` + Agents []client.Agent `json:"agents"` } auditReport := audit{ WorkspaceCount: len(workspaces), @@ -117,7 +117,7 @@ var platformHealthCmd = &cobra.Command{ } func runPlatformHealth(cmd *cobra.Command, _ []string) error { - cl := client.New(apiURL) + cl := newClient() h, err := cl.Health() if err != nil { // Fall back to raw check if /health 404s on older platforms. @@ -150,4 +150,4 @@ func platformRawHealth(baseURL string) ([]byte, error) { } defer resp.Body.Close() return io.ReadAll(resp.Body) -} \ No newline at end of file +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 4703165..fbfffa3 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( + "bytes" "encoding/json" "fmt" "os" @@ -10,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" + "go.moleculesai.app/cli/internal/client" "gopkg.in/yaml.v3" ) @@ -20,6 +22,7 @@ var Version = "dev" var ( verbose bool outputFormat string + jsonOutput bool configPath string apiURL string ) @@ -38,8 +41,8 @@ Quick start: molecule workspace list molecule agent list molecule platform health`, - SilenceUsage: true, - SilenceErrors: true, + SilenceUsage: true, + SilenceErrors: true, } func init() { @@ -50,8 +53,16 @@ func init() { "Enable verbose (DEBUG-level) output to stderr") rootCmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "table", "Output format: table | json | yaml") + rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, + "Shorthand for --output json") rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "Path to config file (default ~/.config/molecule.yaml or ./molecule.yaml)") + // --json wins over -o; resolved before any command runs. + rootCmd.PersistentPreRun = func(_ *cobra.Command, _ []string) { + if jsonOutput { + outputFormat = "json" + } + } rootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { return &exitError{code: 2, msg: err.Error()} }) @@ -82,6 +93,24 @@ func envOr(key, fallback string) string { return fallback } +// newClient builds a Platform client authenticated with the management API +// key (MOLECULE_API_KEY) and org id (MOLECULE_ORG_ID). All management verbs +// go through this so they don't 401 a hardened tenant. +func newClient() *client.Platform { + return client.NewWithAuth(apiURL, authToken(), orgID()) +} + +// cpURL is the control-plane base URL for org-lifecycle verbs (org +// list/get/create/export). Org ops live on the CP (api.moleculesai.app), +// not the per-org tenant host. Defaults to the tenant api-url when unset so +// a combined dev host still works. +func cpURL() string { + if v := os.Getenv("MOLECULE_CP_URL"); v != "" { + return v + } + return apiURL +} + // init registers all subcommand trees. func init() { rootCmd.AddCommand(workspaceCmd) @@ -90,10 +119,20 @@ func init() { rootCmd.AddCommand(configCmd) rootCmd.AddCommand(initCmd) rootCmd.AddCommand(connectCmd) + // Management verbs (PLATFORM-MANAGEMENT-API.md §5(b)). + rootCmd.AddCommand(orgCmd) + rootCmd.AddCommand(secretCmd) + rootCmd.AddCommand(templateCmd) + rootCmd.AddCommand(bundleCmd) + rootCmd.AddCommand(eventsCmd) + rootCmd.AddCommand(approvalsCmd) } // exitError wraps a user-facing error with a specific exit code. -type exitError struct{ code int; msg string } +type exitError struct { + code int + msg string +} func (e *exitError) Error() string { return e.msg } @@ -116,6 +155,31 @@ func printJSON(v interface{}) error { return json.NewEncoder(os.Stdout).Encode(v) } +// printRaw pretty-prints a raw JSON response body to stdout. Used by verbs +// that wrap loose/pass-through endpoints (events, secrets lists, budget, +// exports, allowlist). Honors --output yaml by re-marshaling through a +// generic value; otherwise prints indented JSON. +func printRaw(raw []byte) error { + if len(raw) == 0 { + return nil + } + if outputFormat == "yaml" { + var v interface{} + if err := json.Unmarshal(raw, &v); err != nil { + return err + } + return printYAML(v) + } + var buf bytes.Buffer + if err := json.Indent(&buf, raw, "", " "); err != nil { + // Not valid JSON — print verbatim. + fmt.Println(string(raw)) + return nil + } + fmt.Println(buf.String()) + return nil +} + // printYAML writes v as YAML to stdout. func printYAML(v interface{}) error { enc := yaml.NewEncoder(os.Stdout) @@ -133,4 +197,4 @@ func kv(w *tabwriter.Writer, k, v string) { func versionInfo() string { return fmt.Sprintf("molecule %s (go %s)", Version, runtime.Version()) -} \ No newline at end of file +} diff --git a/internal/cmd/secret.go b/internal/cmd/secret.go new file mode 100644 index 0000000..f0a3984 --- /dev/null +++ b/internal/cmd/secret.go @@ -0,0 +1,132 @@ +// Package cmd implements the CLI command tree. +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// --------------------------------------------------------------------------- +// Secret command group (PLATFORM-MANAGEMENT-API.md §5(b)): +// molecule secret ws {list , set , delete } +// molecule secret org {list, set , delete } +// +// Workspace secrets ARE the workspace env vars; setting one auto-restarts the +// workspace. Org secrets are org-wide (AdminAuth). Values are never returned +// by list. Both go to the tenant host with the Org API Key. +// --------------------------------------------------------------------------- + +var secretCmd = &cobra.Command{ + Use: "secret", + Short: "Manage workspace and org secrets", +} + +func init() { + secretCmd.AddCommand(secretWSCmd, secretOrgCmd) + secretWSCmd.AddCommand(secretWSListCmd, secretWSSetCmd, secretWSDeleteCmd) + secretOrgCmd.AddCommand(secretOrgListCmd, secretOrgSetCmd, secretOrgDeleteCmd) +} + +// --- workspace secrets ------------------------------------------------------ + +var secretWSCmd = &cobra.Command{ + Use: "ws", + Short: "Manage per-workspace secrets (env vars)", +} + +var secretWSListCmd = &cobra.Command{ + Use: "list ", + Short: "List a workspace's secret keys (values not shown)", + Args: cobra.ExactArgs(1), + RunE: runSecretWSList, +} + +func runSecretWSList(_ *cobra.Command, args []string) error { + raw, err := newClient().ListWorkspaceSecrets(args[0]) + if err != nil { + return fmt.Errorf("secret ws list: %w", err) + } + return printRaw(raw) +} + +var secretWSSetCmd = &cobra.Command{ + Use: "set ", + Short: "Set a workspace secret (auto-restarts the workspace)", + Args: cobra.ExactArgs(3), + RunE: runSecretWSSet, +} + +func runSecretWSSet(_ *cobra.Command, args []string) error { + if err := newClient().SetWorkspaceSecret(args[0], args[1], args[2]); err != nil { + return fmt.Errorf("secret ws set: %w", err) + } + fmt.Printf("Secret %q set on workspace %s (workspace restarting).\n", args[1], args[0]) + return nil +} + +var secretWSDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a workspace secret by key", + Args: cobra.ExactArgs(2), + RunE: runSecretWSDelete, +} + +func runSecretWSDelete(_ *cobra.Command, args []string) error { + if err := newClient().DeleteWorkspaceSecret(args[0], args[1]); err != nil { + return fmt.Errorf("secret ws delete: %w", err) + } + fmt.Printf("Secret %q deleted from workspace %s.\n", args[1], args[0]) + return nil +} + +// --- org secrets ------------------------------------------------------------ + +var secretOrgCmd = &cobra.Command{ + Use: "org", + Short: "Manage org-wide secrets", +} + +var secretOrgListCmd = &cobra.Command{ + Use: "list", + Short: "List org-wide secret keys (values not shown)", + RunE: runSecretOrgList, +} + +func runSecretOrgList(_ *cobra.Command, _ []string) error { + raw, err := newClient().ListOrgSecrets() + if err != nil { + return fmt.Errorf("secret org list: %w", err) + } + return printRaw(raw) +} + +var secretOrgSetCmd = &cobra.Command{ + Use: "set ", + Short: "Set an org-wide secret", + Args: cobra.ExactArgs(2), + RunE: runSecretOrgSet, +} + +func runSecretOrgSet(_ *cobra.Command, args []string) error { + if err := newClient().SetOrgSecret(args[0], args[1]); err != nil { + return fmt.Errorf("secret org set: %w", err) + } + fmt.Printf("Org secret %q set.\n", args[0]) + return nil +} + +var secretOrgDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete an org-wide secret by key", + Args: cobra.ExactArgs(1), + RunE: runSecretOrgDelete, +} + +func runSecretOrgDelete(_ *cobra.Command, args []string) error { + if err := newClient().DeleteOrgSecret(args[0]); err != nil { + return fmt.Errorf("secret org delete: %w", err) + } + fmt.Printf("Org secret %q deleted.\n", args[0]) + return nil +} diff --git a/internal/cmd/template.go b/internal/cmd/template.go new file mode 100644 index 0000000..9399726 --- /dev/null +++ b/internal/cmd/template.go @@ -0,0 +1,114 @@ +// Package cmd implements the CLI command tree. +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" +) + +// --------------------------------------------------------------------------- +// Template command group (PLATFORM-MANAGEMENT-API.md §5(b)): +// molecule template {list, import --name --file =..., refresh} +// All go to the tenant host with the Org API Key (AdminAuth). +// --------------------------------------------------------------------------- + +var templateCmd = &cobra.Command{ + Use: "template", + Short: "Manage workspace templates", +} + +func init() { + templateCmd.AddCommand(templateListCmd, templateImportCmd, templateRefreshCmd) +} + +var templateListCmd = &cobra.Command{ + Use: "list", + Short: "List workspace templates", + RunE: runTemplateList, +} + +func runTemplateList(_ *cobra.Command, _ []string) error { + raw, err := newClient().ListTemplates() + if err != nil { + return fmt.Errorf("template list: %w", err) + } + return printRaw(raw) +} + +var templateImportFlags struct { + name string + files []string // KEY=PATH pairs +} + +var templateImportCmd = &cobra.Command{ + Use: "import --name --file = [--file ...]", + Short: "Import a template from local files", + Long: `Imports a template (POST /templates/import). Each --file maps a path +inside the template to a local file whose contents are read and uploaded. + + molecule template import --name my-tmpl \ + --file org.yaml=./org.yaml \ + --file config.yaml=./config.yaml`, + RunE: runTemplateImport, +} + +func init() { + f := templateImportCmd.Flags() + f.StringVar(&templateImportFlags.name, "name", "", "Template name (required)") + f.StringArrayVar(&templateImportFlags.files, "file", nil, "Template file mapping relpath=localpath (repeatable, required)") + templateImportCmd.MarkFlagRequired("name") +} + +func runTemplateImport(_ *cobra.Command, _ []string) error { + if len(templateImportFlags.files) == 0 { + return &exitError{code: 2, msg: "template import: at least one --file relpath=localpath is required"} + } + files, err := readFileMappings(templateImportFlags.files) + if err != nil { + return &exitError{code: 2, msg: "template import: " + err.Error()} + } + raw, err := newClient().ImportTemplate(templateImportFlags.name, files) + if err != nil { + return fmt.Errorf("template import: %w", err) + } + return printRaw(raw) +} + +// readFileMappings parses "relpath=localpath" pairs and reads each local file +// into the returned map[relpath]contents. +func readFileMappings(pairs []string) (map[string]string, error) { + out := make(map[string]string, len(pairs)) + for _, p := range pairs { + rel, local, ok := strings.Cut(p, "=") + if !ok || rel == "" || local == "" { + return nil, fmt.Errorf("invalid --file %q (want relpath=localpath)", p) + } + data, err := os.ReadFile(local) + if err != nil { + return nil, fmt.Errorf("read %s: %w", local, err) + } + out[rel] = string(data) + } + return out, nil +} + +var templateRefreshCmd = &cobra.Command{ + Use: "refresh", + Short: "Refresh the template cache", + RunE: runTemplateRefresh, +} + +func runTemplateRefresh(_ *cobra.Command, _ []string) error { + raw, err := newClient().RefreshTemplates() + if err != nil { + return fmt.Errorf("template refresh: %w", err) + } + if len(raw) == 0 { + fmt.Println("Template cache refresh triggered.") + return nil + } + return printRaw(raw) +} diff --git a/internal/cmd/workspace.go b/internal/cmd/workspace.go index 9986187..a058891 100644 --- a/internal/cmd/workspace.go +++ b/internal/cmd/workspace.go @@ -7,8 +7,8 @@ import ( "os" "text/tabwriter" - "go.moleculesai.app/cli/internal/client" "github.com/spf13/cobra" + "go.moleculesai.app/cli/internal/client" ) // --------------------------------------------------------------------------- @@ -25,7 +25,10 @@ func init() { workspaceCmd.AddCommand( workspaceListCmd, workspaceCreateCmd, workspaceInspectCmd, workspaceDeleteCmd, workspaceRestartCmd, workspaceAuditCmd, workspaceDelegateCmd, + workspaceGetCmd, workspacePauseCmd, workspaceResumeCmd, + workspaceBudgetCmd, workspaceBillingModeCmd, workspaceTokenCmd, ) + workspaceTokenCmd.AddCommand(workspaceTokenMintCmd) } // =========================================================================== @@ -38,7 +41,7 @@ var workspaceListCmd = &cobra.Command{ } func runWorkspaceList(cmd *cobra.Command, _ []string) error { - cl := client.New(apiURL) + cl := newClient() ws, err := cl.ListWorkspaces() if err != nil { return fmt.Errorf("workspace list: %w", err) @@ -94,7 +97,7 @@ func init() { } func runWorkspaceCreate(cmd *cobra.Command, _ []string) error { - cl := client.New(apiURL) + cl := newClient() req := client.CreateWorkspaceRequest{Name: createFlags.name} if createFlags.role != "" { req.Role = createFlags.role @@ -139,7 +142,7 @@ var workspaceInspectCmd = &cobra.Command{ } func runWorkspaceInspect(cmd *cobra.Command, args []string) error { - cl := client.New(apiURL) + cl := newClient() ws, err := cl.GetWorkspace(args[0]) if err != nil { return fmt.Errorf("workspace inspect: %w", err) @@ -177,7 +180,7 @@ var workspaceDeleteCmd = &cobra.Command{ } func runWorkspaceDelete(cmd *cobra.Command, args []string) error { - cl := client.New(apiURL) + cl := newClient() if err := cl.DeleteWorkspace(args[0]); err != nil { return fmt.Errorf("workspace delete: %w", err) } @@ -196,7 +199,7 @@ var workspaceRestartCmd = &cobra.Command{ } func runWorkspaceRestart(cmd *cobra.Command, args []string) error { - cl := client.New(apiURL) + cl := newClient() if err := cl.RestartWorkspace(args[0]); err != nil { return fmt.Errorf("workspace restart: %w", err) } @@ -214,17 +217,17 @@ var workspaceAuditCmd = &cobra.Command{ } func runWorkspaceAudit(cmd *cobra.Command, _ []string) error { - cl := client.New(apiURL) + cl := newClient() workspaces, agents, err := cl.AuditWorkspaces() if err != nil { return fmt.Errorf("workspace audit: %w", err) } type auditReport struct { - Workspaces int `json:"workspaces"` - Agents int `json:"agents"` - ByStatus map[string]int `json:"by_status"` - Items []client.Workspace `json:"workspaces_list"` - AgentList []client.Agent `json:"agents_list"` + Workspaces int `json:"workspaces"` + Agents int `json:"agents"` + ByStatus map[string]int `json:"by_status"` + Items []client.Workspace `json:"workspaces_list"` + AgentList []client.Agent `json:"agents_list"` } byStatus := map[string]int{} for _, ws := range workspaces { @@ -272,7 +275,7 @@ var workspaceDelegateCmd = &cobra.Command{ func runWorkspaceDelegate(cmd *cobra.Command, args []string) error { workspaceID, targetID, task := args[0], args[1], args[2] - cl := client.New(apiURL) + cl := newClient() type delReq struct { TargetID string `json:"target_id"` @@ -298,4 +301,4 @@ func runWorkspaceDelegate(cmd *cobra.Command, args []string) error { } _ = workspaceID return nil -} \ No newline at end of file +} diff --git a/internal/cmd/workspace_mgmt.go b/internal/cmd/workspace_mgmt.go new file mode 100644 index 0000000..3d0b980 --- /dev/null +++ b/internal/cmd/workspace_mgmt.go @@ -0,0 +1,192 @@ +// Package cmd implements the CLI command tree. +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// --------------------------------------------------------------------------- +// Workspace management verbs (PLATFORM-MANAGEMENT-API.md §5(b)): +// get, pause, resume, budget, billing-mode, token mint. +// list / create / delete / restart / inspect live in workspace.go. +// --------------------------------------------------------------------------- + +// =========================================================================== +// molecule workspace get (alias of inspect) +// =========================================================================== +var workspaceGetCmd = &cobra.Command{ + Use: "get ", + Short: "Show full details for a workspace (alias of inspect)", + Args: cobra.ExactArgs(1), + RunE: runWorkspaceInspect, +} + +// =========================================================================== +// molecule workspace pause +// =========================================================================== +var workspacePauseCmd = &cobra.Command{ + Use: "pause ", + Short: "Pause a workspace", + Args: cobra.ExactArgs(1), + RunE: runWorkspacePause, +} + +func runWorkspacePause(_ *cobra.Command, args []string) error { + if err := newClient().PauseWorkspace(args[0]); err != nil { + return fmt.Errorf("workspace pause: %w", err) + } + fmt.Printf("Pause triggered for workspace %q.\n", args[0]) + return nil +} + +// =========================================================================== +// molecule workspace resume +// =========================================================================== +var workspaceResumeCmd = &cobra.Command{ + Use: "resume ", + Short: "Resume a paused workspace", + Args: cobra.ExactArgs(1), + RunE: runWorkspaceResume, +} + +func runWorkspaceResume(_ *cobra.Command, args []string) error { + if err := newClient().ResumeWorkspace(args[0]); err != nil { + return fmt.Errorf("workspace resume: %w", err) + } + fmt.Printf("Resume triggered for workspace %q.\n", args[0]) + return nil +} + +// =========================================================================== +// molecule workspace budget [--hourly|--daily|--weekly|--monthly cents] +// =========================================================================== +var budgetFlags struct { + hourly int64 + daily int64 + weekly int64 + monthly int64 +} + +var workspaceBudgetCmd = &cobra.Command{ + Use: "budget [--hourly|--daily|--weekly|--monthly ]", + Short: "Show or set a workspace's LLM budget (USD cents per period)", + Long: `With no period flags, prints the current budget. +With one or more period flags, sets those period limits (USD cents). +Pass a period flag with value -1 to CLEAR that period's limit. + + molecule workspace budget ws_123 # show + molecule workspace budget ws_123 --monthly 50000 # $500/mo + molecule workspace budget ws_123 --daily -1 # clear daily limit`, + Args: cobra.ExactArgs(1), + RunE: runWorkspaceBudget, +} + +func init() { + f := workspaceBudgetCmd.Flags() + // Sentinel: math.MinInt64 means "flag not provided". -1 means "clear". + f.Int64Var(&budgetFlags.hourly, "hourly", budgetUnset, "Hourly limit (USD cents); -1 to clear") + f.Int64Var(&budgetFlags.daily, "daily", budgetUnset, "Daily limit (USD cents); -1 to clear") + f.Int64Var(&budgetFlags.weekly, "weekly", budgetUnset, "Weekly limit (USD cents); -1 to clear") + f.Int64Var(&budgetFlags.monthly, "monthly", budgetUnset, "Monthly limit (USD cents); -1 to clear") +} + +const budgetUnset = -1 << 62 + +func runWorkspaceBudget(cmd *cobra.Command, args []string) error { + cl := newClient() + id := args[0] + + limits := map[string]*int64{} + add := func(period string, v int64) { + if v == budgetUnset { + return + } + if v < 0 { + limits[period] = nil // clear + return + } + vv := v + limits[period] = &vv + } + add("hourly", budgetFlags.hourly) + add("daily", budgetFlags.daily) + add("weekly", budgetFlags.weekly) + add("monthly", budgetFlags.monthly) + + if len(limits) == 0 { + // Show current budget. + raw, err := cl.GetBudget(id) + if err != nil { + return fmt.Errorf("workspace budget: %w", err) + } + return printRaw(raw) + } + raw, err := cl.SetBudget(id, limits) + if err != nil { + return fmt.Errorf("workspace budget: %w", err) + } + return printRaw(raw) +} + +// =========================================================================== +// molecule workspace billing-mode +// =========================================================================== +var workspaceBillingModeCmd = &cobra.Command{ + Use: "billing-mode ", + Short: "Set a workspace's LLM billing-mode override", + Args: cobra.ExactArgs(2), + RunE: runWorkspaceBillingMode, +} + +func runWorkspaceBillingMode(_ *cobra.Command, args []string) error { + id, mode := args[0], args[1] + switch mode { + case "platform_managed", "byok", "disabled": + // ok + case "clear", "null", "": + mode = "" // clear override + default: + return &exitError{code: 2, msg: "workspace billing-mode: mode must be platform_managed, byok, disabled, or clear"} + } + raw, err := newClient().SetBillingMode(id, mode) + if err != nil { + return fmt.Errorf("workspace billing-mode: %w", err) + } + return printRaw(raw) +} + +// =========================================================================== +// molecule workspace token mint +// =========================================================================== +var workspaceTokenCmd = &cobra.Command{ + Use: "token", + Short: "Manage per-workspace auth tokens", +} + +var workspaceTokenMintCmd = &cobra.Command{ + Use: "mint ", + Short: "Mint a new per-workspace auth token (plaintext shown once)", + Args: cobra.ExactArgs(1), + RunE: runWorkspaceTokenMint, +} + +func runWorkspaceTokenMint(_ *cobra.Command, args []string) error { + resp, err := newClient().MintWorkspaceToken(args[0]) + if err != nil { + return fmt.Errorf("workspace token mint: %w", err) + } + if outputFormat == "json" { + return printJSON(resp) + } + if outputFormat == "yaml" { + return printYAML(resp) + } + fmt.Printf("Token minted for workspace %s\n", resp.WorkspaceID) + fmt.Printf("auth_token: %s\n", resp.Token) + if resp.Message != "" { + fmt.Printf("%s\n", resp.Message) + } + return nil +} -- 2.52.0 From 442d1ebaf40c3c23aa3b2156574b8261543bfcb9 Mon Sep 17 00:00:00 2001 From: sdk-dev Date: Sun, 31 May 2026 22:35:22 -0700 Subject: [PATCH 2/4] fix(cli): repoint org create/list to CP-admin bearer; fail-fast get/export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fix for #13. The CP org verbs targeted /api/v1/orgs*, which is gated by RequireSession() (WorkOS cookie-only) — a bearer-token CLI can't authenticate and these 401 in prod; the tenant Org API Key has no standing on the CP at all. - org create/list now target the CP ADMIN routes (POST/GET /api/v1/admin/orgs, AdminGate bearer), authenticated with a DISTINCT credential MOLECULE_CP_ADMIN_TOKEN (never the tenant MOLECULE_API_KEY). create now requires --owner-user-id, per controlplane adminCreateOrgRequest{slug,name,owner_user_id}. ListOrgs decodes the {limit,offset,orgs[]} admin-summary envelope. Two-credential split is documented in `org`/`org create` help text; the org key is never sent to the CP. - org get/export have NO AdminGate-reachable route on the CP (session-only), so they fail fast with a clear "session-only, use the dashboard" error instead of shipping verbs that 401. - cpAdminClient() fails fast with guidance when MOLECULE_CP_ADMIN_TOKEN is unset (wrong-credential path), rather than silently sending the org key to the CP. - Wire Execute() through handleErr so SilenceErrors'd exitError messages actually print (they were previously swallowed by main's bare os.Exit(1)) — required for the fail-fast guidance to reach the user. - Optional cleanup: extract resolveBillingMode()/budgetLimitsFromFlags() so prod and tests share one definition. - Tests: client + cmd assert org verbs hit /api/v1/admin/orgs with the CP-admin bearer (no org-id header, no org-key leak), the missing-owner and missing-admin-token fail-fast paths, get/export fail-fast, and an e2e CLI test that `org list` without the admin token exits non-zero naming MOLECULE_CP_ADMIN_TOKEN. Budget shape (budget_limits) left unchanged — confirmed correct; the OpenAPI spec is the stale one (fixed separately on #2056). Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/molecule/molecule_test.go | 41 +++++++ internal/client/management.go | 85 ++++++++------ internal/client/management_test.go | 35 +++--- internal/cmd/http.go | 14 +++ internal/cmd/management_test.go | 44 ++------ internal/cmd/org.go | 142 ++++++++++++++---------- internal/cmd/org_test.go | 171 +++++++++++++++++++++++++++++ internal/cmd/root.go | 22 +++- internal/cmd/workspace_mgmt.go | 55 +++++++--- 9 files changed, 440 insertions(+), 169 deletions(-) create mode 100644 internal/cmd/org_test.go diff --git a/cmd/molecule/molecule_test.go b/cmd/molecule/molecule_test.go index 8930aba..e17b8fe 100644 --- a/cmd/molecule/molecule_test.go +++ b/cmd/molecule/molecule_test.go @@ -669,6 +669,47 @@ func TestCLI_PlatformAudit(t *testing.T) { } } +// TestCLI_OrgList_FailsFastWithoutCPAdminToken is the end-to-end guard for the +// org-verb credential split: `org list` targets the CP ADMIN surface and must +// fail fast (non-zero exit) with a clear stderr message naming +// MOLECULE_CP_ADMIN_TOKEN when that token is absent — instead of silently +// sending the tenant Org API Key to the control plane and 401'ing. This also +// covers the error-surfacing wiring (SilenceErrors → handleErr) so the message +// actually reaches the user. +func TestCLI_OrgList_FailsFastWithoutCPAdminToken(t *testing.T) { + exe := mol(t) + root := repoRoot() + cmd := exec.Command(exe, "org", "list") + // Tenant org key present; CP-admin token deliberately unset. + cmd.Env = append(os.Environ(), + "MOLECULE_API_KEY=org-key-SECRET", + "MOLECULE_ORG_ID=org_abc", + "MOLECULE_CP_ADMIN_TOKEN=", + ) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.Dir = root + err := cmd.Run() + if err == nil { + t.Fatalf("expected non-zero exit for org list without CP admin token, got none\nstdout: %s", stdout.String()) + } + exitErr, ok := err.(*exec.ExitError) + if !ok { + t.Fatalf("expected *exec.ExitError, got %T", err) + } + if exitErr.ExitCode() == 0 { + t.Errorf("expected non-zero exit code, got 0") + } + if !strings.Contains(stderr.String(), "MOLECULE_CP_ADMIN_TOKEN") { + t.Errorf("stderr must name MOLECULE_CP_ADMIN_TOKEN, got:\n%s", stderr.String()) + } + // The tenant org key must never appear in any output (not leaked). + if strings.Contains(stdout.String()+stderr.String(), "org-key-SECRET") { + t.Errorf("tenant Org API Key leaked into output") + } +} + func TestCLI_UnknownSubcommand(t *testing.T) { exe := mol(t) root := repoRoot() diff --git a/internal/client/management.go b/internal/client/management.go index f7365fe..549787a 100644 --- a/internal/client/management.go +++ b/internal/client/management.go @@ -17,62 +17,75 @@ import ( ) // --------------------------------------------------------------------------- -// Control-plane: orgs +// Control-plane: orgs (ADMIN bearer surface — /api/v1/admin/orgs) +// +// The customer-facing /api/v1/orgs* routes are gated by a WorkOS browser +// session (controlplane auth.RequireSession), which a bearer-token CLI cannot +// satisfy — every call 401s in production, and the tenant Org API Key has no +// standing on the CP at all. So org-lifecycle verbs go to the CP ADMIN routes +// (controlplane router AdminGate), authenticated with a DISTINCT CP-admin +// bearer (MOLECULE_CP_ADMIN_TOKEN). The org key is never sent to the CP. +// +// Note: only create + list exist on the admin surface. There is NO admin +// GET /orgs/:slug and NO admin /orgs/:slug/export in controlplane — those are +// session-only — so the CLI fails those verbs fast rather than 401'ing. // --------------------------------------------------------------------------- -// Org mirrors controlplane models.Organization (GET /api/v1/orgs[/:slug]). +// Org mirrors controlplane handlers.adminOrgSummary, the per-row shape of +// GET /api/v1/admin/orgs. Intentionally narrower than the session-facing +// customer org view (no credits/overage — those aren't on the admin summary). type Org struct { - ID string `json:"id"` - Slug string `json:"slug"` - Name string `json:"name"` - Plan string `json:"plan"` - Status string `json:"status"` - CreditsBalance int64 `json:"credits_balance"` - PlanMonthlyCredits int64 `json:"plan_monthly_credits"` - OverageUsedCredits int64 `json:"overage_used_credits"` - OverageCapCredits int64 `json:"overage_cap_credits"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Plan string `json:"plan"` + Status string `json:"status,omitempty"` + StripeCustomerID string `json:"stripe_customer_id,omitempty"` + MemberCount int `json:"member_count,omitempty"` + InstanceStatus string `json:"instance_status,omitempty"` + LastError string `json:"last_error,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at,omitempty"` } -// ListOrgs returns the caller's orgs from the control plane. +// adminListOrgsResponse is the envelope returned by GET /api/v1/admin/orgs: +// {"limit":N,"offset":N,"orgs":[...]}. +type adminListOrgsResponse struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + Orgs []Org `json:"orgs"` +} + +// ListOrgs returns all orgs from the CP admin surface +// (GET /api/v1/admin/orgs). Requires a CP-admin bearer. func (p *Platform) ListOrgs() ([]Org, error) { - var out []Org - if err := p.getInto("/api/v1/orgs", &out); err != nil { + var out adminListOrgsResponse + if err := p.getInto("/api/v1/admin/orgs", &out); err != nil { return nil, err } - return out, nil + return out.Orgs, nil } -// GetOrg returns a single org by slug from the control plane. -func (p *Platform) GetOrg(slug string) (*Org, error) { - var out Org - if err := p.getInto("/api/v1/orgs/"+url.PathEscape(slug), &out); err != nil { - return nil, err - } - return &out, nil -} - -// CreateOrgRequest is the body for POST /api/v1/orgs. +// CreateOrgRequest is the body for POST /api/v1/admin/orgs. owner_user_id is +// REQUIRED by the admin route (controlplane adminCreateOrgRequest): the +// server-to-server path has no implicit WorkOS session to hang the owner +// membership off of, so the caller passes it explicitly. type CreateOrgRequest struct { - Slug string `json:"slug"` - Name string `json:"name"` + Slug string `json:"slug"` + Name string `json:"name"` + OwnerUserID string `json:"owner_user_id"` } -// CreateOrg creates an org on the control plane. +// CreateOrg creates an org on the CP admin surface (POST /api/v1/admin/orgs). +// Requires a CP-admin bearer. Returns the full created org. func (p *Platform) CreateOrg(req CreateOrgRequest) (*Org, error) { var out Org - if err := p.postInto("/api/v1/orgs", req, &out); err != nil { + if err := p.postInto("/api/v1/admin/orgs", req, &out); err != nil { return nil, err } return &out, nil } -// ExportOrg returns the org export payload (GET /api/v1/orgs/:slug/export). -func (p *Platform) ExportOrg(slug string) (json.RawMessage, error) { - return p.getRaw("/api/v1/orgs/" + url.PathEscape(slug) + "/export") -} - // --------------------------------------------------------------------------- // Tenant: org-from-template (POST /org/import) + allowlist + org tokens // --------------------------------------------------------------------------- diff --git a/internal/client/management_test.go b/internal/client/management_test.go index 23cbf73..9dcc93f 100644 --- a/internal/client/management_test.go +++ b/internal/client/management_test.go @@ -71,33 +71,26 @@ func TestManagementRequestConstruction(t *testing.T) { wantBody string // exact JSON when non-empty; "" = don't assert body }{ { + // org list now hits the CP ADMIN surface (the customer + // /api/v1/orgs is WorkOS-session-gated and 401s a bearer CLI). name: "ListOrgs", - reply: `[]`, + reply: `{"limit":100,"offset":0,"orgs":[]}`, call: func(p *Platform) error { _, e := p.ListOrgs(); return e }, wantMethod: "GET", - wantPath: "/api/v1/orgs", + wantPath: "/api/v1/admin/orgs", }, { - name: "GetOrg", - reply: `{"slug":"acme"}`, - call: func(p *Platform) error { _, e := p.GetOrg("acme"); return e }, - wantMethod: "GET", - wantPath: "/api/v1/orgs/acme", - }, - { - name: "CreateOrg", - reply: `{"slug":"acme","name":"Acme"}`, - call: func(p *Platform) error { _, e := p.CreateOrg(CreateOrgRequest{Slug: "acme", Name: "Acme"}); return e }, + // org create hits the CP ADMIN surface and carries the required + // owner_user_id (the admin route has no implicit session). + name: "CreateOrg", + reply: `{"slug":"acme","name":"Acme"}`, + call: func(p *Platform) error { + _, e := p.CreateOrg(CreateOrgRequest{Slug: "acme", Name: "Acme", OwnerUserID: "user_123"}) + return e + }, wantMethod: "POST", - wantPath: "/api/v1/orgs", - wantBody: `{"slug":"acme","name":"Acme"}`, - }, - { - name: "ExportOrg", - reply: `{"org":"acme"}`, - call: func(p *Platform) error { _, e := p.ExportOrg("acme"); return e }, - wantMethod: "GET", - wantPath: "/api/v1/orgs/acme/export", + wantPath: "/api/v1/admin/orgs", + wantBody: `{"slug":"acme","name":"Acme","owner_user_id":"user_123"}`, }, { name: "CreateOrgFromTemplate", diff --git a/internal/cmd/http.go b/internal/cmd/http.go index 1b1b740..33a79b5 100644 --- a/internal/cmd/http.go +++ b/internal/cmd/http.go @@ -22,6 +22,20 @@ func orgID() string { return os.Getenv("MOLECULE_ORG_ID") } +// cpAdminToken returns the control-plane ADMIN bearer used for org-lifecycle +// verbs that hit the CP admin surface (POST/GET /api/v1/admin/orgs). +// +// This is a DISTINCT credential from MOLECULE_API_KEY (the tenant Org API +// Key). The CP's customer-facing /api/v1/orgs* routes are gated by a WorkOS +// browser session (RequireSession), which a bearer-token CLI cannot satisfy — +// and the Org API Key has no standing on the CP at all. The admin routes are +// gated by AdminGate, which accepts a server-to-server bearer. We deliberately +// keep this in its own env var so the tenant org key is NEVER sent to the CP. +// Read from MOLECULE_CP_ADMIN_TOKEN. +func cpAdminToken() string { + return os.Getenv("MOLECULE_CP_ADMIN_TOKEN") +} + // setAuthHeaders attaches the management credentials to req. // // Before this existed, management calls (workspace create/delete, secrets, diff --git a/internal/cmd/management_test.go b/internal/cmd/management_test.go index e73b607..478564f 100644 --- a/internal/cmd/management_test.go +++ b/internal/cmd/management_test.go @@ -8,37 +8,14 @@ import ( // TestBudgetFlagMapping verifies the budget flag→limits translation: // unset flags are omitted, negative clears (nil), non-negative sets a pointer. +// Exercises the SHARED budgetLimitsFromFlags helper so prod and test agree. func TestBudgetFlagMapping(t *testing.T) { - build := func(hourly, daily, weekly, monthly int64) map[string]*int64 { - budgetFlags.hourly = hourly - budgetFlags.daily = daily - budgetFlags.weekly = weekly - budgetFlags.monthly = monthly - limits := map[string]*int64{} - add := func(period string, v int64) { - if v == budgetUnset { - return - } - if v < 0 { - limits[period] = nil - return - } - vv := v - limits[period] = &vv - } - add("hourly", budgetFlags.hourly) - add("daily", budgetFlags.daily) - add("weekly", budgetFlags.weekly) - add("monthly", budgetFlags.monthly) - return limits - } - // All unset → empty map (show, not set). - if got := build(budgetUnset, budgetUnset, budgetUnset, budgetUnset); len(got) != 0 { + if got := budgetLimitsFromFlags(budgetUnset, budgetUnset, budgetUnset, budgetUnset); len(got) != 0 { t.Errorf("all-unset: want empty map, got %v", got) } // monthly=50000 set, daily=-1 clear, others unset. - got := build(budgetUnset, -1, budgetUnset, 50000) + got := budgetLimitsFromFlags(budgetUnset, -1, budgetUnset, 50000) if len(got) != 2 { t.Fatalf("want 2 entries, got %d (%v)", len(got), got) } @@ -53,7 +30,8 @@ func TestBudgetFlagMapping(t *testing.T) { } } -// TestBillingModeValidation walks the billing-mode arg validation branches. +// TestBillingModeValidation walks the billing-mode arg validation branches via +// the SHARED resolveBillingMode helper (same code prod runs). func TestBillingModeValidation(t *testing.T) { cases := []struct { in string @@ -69,16 +47,8 @@ func TestBillingModeValidation(t *testing.T) { {"bogus", "", true}, } for _, tc := range cases { - mode := tc.in - var err bool - switch mode { - case "platform_managed", "byok", "disabled": - case "clear", "null", "": - mode = "" - default: - err = true - } - if err != tc.wantErr { + mode, err := resolveBillingMode(tc.in) + if (err != nil) != tc.wantErr { t.Errorf("%q: err=%v want %v", tc.in, err, tc.wantErr) } if !tc.wantErr && mode != tc.wantMode { diff --git a/internal/cmd/org.go b/internal/cmd/org.go index 9122ec6..1cc6601 100644 --- a/internal/cmd/org.go +++ b/internal/cmd/org.go @@ -13,21 +13,40 @@ import ( // --------------------------------------------------------------------------- // Org command group. // -// Org-lifecycle verbs (list / get / create / export) talk to the CONTROL -// PLANE (MOLECULE_CP_URL, default = api-url) — orgs/billing/provisioning live -// there, not on a tenant host. The tenant-scoped sub-verbs (create -// --template, token *, allowlist) talk to the tenant host (api-url) with the -// Org API Key, per PLATFORM-MANAGEMENT-API.md §1/§3. +// TWO credentials, two surfaces — do not conflate them: +// +// - Org-LIFECYCLE verbs (create / list) talk to the CONTROL-PLANE ADMIN API +// (MOLECULE_CP_URL, default = api-url) and need a CP-ADMIN bearer in +// MOLECULE_CP_ADMIN_TOKEN. The customer-facing /api/v1/orgs* routes are +// WorkOS-session-gated (RequireSession) and cannot be reached by any +// bearer-token CLI; the admin routes (/api/v1/admin/orgs, AdminGate) can. +// The tenant Org API Key is NEVER sent to the control plane. +// +// - org get / org export have NO bearer-reachable CP route (session-only), +// so they fail fast with guidance rather than 401'ing. +// +// - Tenant-scoped sub-verbs (create --template, token *, allowlist) talk to +// the TENANT host (api-url) with the Org API Key (MOLECULE_API_KEY + +// MOLECULE_ORG_ID), per PLATFORM-MANAGEMENT-API.md §1/§3. // --------------------------------------------------------------------------- var orgCmd = &cobra.Command{ Use: "org", Short: "Manage organizations (control plane) and org-scoped tenant resources", -} + Long: `Manage organizations. -// cpClient builds a client pointed at the control plane. -func cpClient() *client.Platform { - return client.NewWithAuth(cpURL(), authToken(), orgID()) +Org-lifecycle verbs (create, list) use the CONTROL-PLANE ADMIN API and require +a CP-admin bearer token — a credential DISTINCT from the tenant Org API Key: + + MOLECULE_CP_ADMIN_TOKEN CP admin bearer (org create/list) + MOLECULE_CP_URL CP base URL (default: --api-url) + +The tenant Org API Key (MOLECULE_API_KEY / MOLECULE_ORG_ID) is used ONLY for +the tenant-scoped sub-verbs (create --template, token *, allowlist) and is +never sent to the control plane. + +org get / org export are not available to token-authenticated callers (the +control plane gates them behind a WorkOS browser session); use the dashboard.`, } func init() { @@ -48,7 +67,11 @@ var orgListCmd = &cobra.Command{ } func runOrgList(_ *cobra.Command, _ []string) error { - orgs, err := cpClient().ListOrgs() + cp, err := cpAdminClient() + if err != nil { + return err + } + orgs, err := cp.ListOrgs() if err != nil { return fmt.Errorf("org list: %w", err) } @@ -63,9 +86,9 @@ func runOrgList(_ *cobra.Command, _ []string) error { return nil } w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) - fmt.Fprintln(w, "SLUG\tNAME\tPLAN\tSTATUS\tCREDITS") + fmt.Fprintln(w, "SLUG\tNAME\tPLAN\tINSTANCE\tMEMBERS") for _, o := range orgs { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\n", o.Slug, o.Name, o.Plan, o.Status, o.CreditsBalance) + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\n", o.Slug, o.Name, o.Plan, o.InstanceStatus, o.MemberCount) } return w.Flush() } @@ -75,59 +98,55 @@ func runOrgList(_ *cobra.Command, _ []string) error { // =========================================================================== var orgGetCmd = &cobra.Command{ Use: "get ", - Short: "Show a single organization (control plane)", - Args: cobra.ExactArgs(1), + Short: "Show a single org (UNAVAILABLE via token auth — session-only on the CP)", + Args: cobra.MaximumNArgs(1), RunE: runOrgGet, } -func runOrgGet(_ *cobra.Command, args []string) error { - o, err := cpClient().GetOrg(args[0]) - if err != nil { - return fmt.Errorf("org get: %w", err) - } - if outputFormat == "json" { - return printJSON(o) - } - if outputFormat == "yaml" { - return printYAML(o) - } - w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) - kv(w, "ID", o.ID) - kv(w, "Slug", o.Slug) - kv(w, "Name", o.Name) - kv(w, "Plan", o.Plan) - kv(w, "Status", o.Status) - kv(w, "Credits", fmt.Sprintf("%d", o.CreditsBalance)) - kv(w, "CreatedAt", o.CreatedAt) - return w.Flush() +func runOrgGet(_ *cobra.Command, _ []string) error { + // The control plane exposes GET /api/v1/orgs/:slug only behind a WorkOS + // browser session (RequireSession); there is no AdminGate-reachable + // admin equivalent. A token-authenticated CLI therefore cannot serve + // this verb — fail fast with guidance instead of shipping a guaranteed + // 401. `org list` (admin-bearer) covers the common "what orgs exist". + return &exitError{code: 2, msg: "org get is not available to token-authenticated callers: the control plane gates GET /api/v1/orgs/:slug behind a WorkOS browser session, and no CP-admin bearer route exists for it. Use the dashboard, or `molecule org list` (needs MOLECULE_CP_ADMIN_TOKEN) for a fleet overview."} } // =========================================================================== // molecule org create // =========================================================================== var orgCreateFlags struct { - slug string - name string - template string - mode string + slug string + name string + ownerUserID string + template string + mode string } var orgCreateCmd = &cobra.Command{ - Use: "create --slug --name | --template ", - Short: "Create an org (control plane) or provision workspaces from an org template (tenant)", - Long: `Create an organization on the control plane: - molecule org create --slug acme --name "Acme Inc" + Use: "create --slug --name --owner-user-id | --template ", + Short: "Create an org (control-plane admin) or provision workspaces from an org template (tenant)", + Long: `Create an organization on the control plane (admin surface): + MOLECULE_CP_ADMIN_TOKEN= \ + molecule org create --slug acme --name "Acme Inc" --owner-user-id user_123 + +This targets POST /api/v1/admin/orgs (AdminGate) — NOT the WorkOS-session-gated +POST /api/v1/orgs, which a bearer-token CLI cannot reach. It requires a CP-admin +bearer in MOLECULE_CP_ADMIN_TOKEN (distinct from the tenant Org API Key +MOLECULE_API_KEY) and an explicit --owner-user-id (the server-to-server path has +no implicit session to own the new org). Or provision workspaces into the current tenant from an org template -directory (POST /org/import): +directory (POST /org/import) — this uses the tenant Org API Key: molecule org create --template my-org-template [--mode merge|reconcile]`, RunE: runOrgCreate, } func init() { f := orgCreateCmd.Flags() - f.StringVar(&orgCreateFlags.slug, "slug", "", "Org slug (control-plane create)") - f.StringVar(&orgCreateFlags.name, "name", "", "Org display name (control-plane create)") + f.StringVar(&orgCreateFlags.slug, "slug", "", "Org slug (control-plane admin create)") + f.StringVar(&orgCreateFlags.name, "name", "", "Org display name (control-plane admin create)") + f.StringVar(&orgCreateFlags.ownerUserID, "owner-user-id", "", "Owner user id for the new org (required for control-plane admin create)") f.StringVar(&orgCreateFlags.template, "template", "", "Org template directory (tenant org-from-template)") f.StringVar(&orgCreateFlags.mode, "mode", "", "Template import mode: merge (default) | reconcile") } @@ -144,13 +163,21 @@ func runOrgCreate(_ *cobra.Command, _ []string) error { } return printRaw(raw) } - // Control-plane org create. + // Control-plane admin org create (POST /api/v1/admin/orgs). if orgCreateFlags.slug == "" || orgCreateFlags.name == "" { - return &exitError{code: 2, msg: "org create: provide --slug and --name (CP create), or --template (tenant org-from-template)"} + return &exitError{code: 2, msg: "org create: provide --slug, --name and --owner-user-id (CP admin create), or --template (tenant org-from-template)"} } - o, err := cpClient().CreateOrg(client.CreateOrgRequest{ - Slug: orgCreateFlags.slug, - Name: orgCreateFlags.name, + if orgCreateFlags.ownerUserID == "" { + return &exitError{code: 2, msg: "org create: --owner-user-id is required for control-plane create (the admin route POST /api/v1/admin/orgs has no implicit session to own the org)"} + } + cp, err := cpAdminClient() + if err != nil { + return err + } + o, err := cp.CreateOrg(client.CreateOrgRequest{ + Slug: orgCreateFlags.slug, + Name: orgCreateFlags.name, + OwnerUserID: orgCreateFlags.ownerUserID, }) if err != nil { return fmt.Errorf("org create: %w", err) @@ -170,17 +197,16 @@ func runOrgCreate(_ *cobra.Command, _ []string) error { // =========================================================================== var orgExportCmd = &cobra.Command{ Use: "export ", - Short: "Export an organization (control plane)", - Args: cobra.ExactArgs(1), + Short: "Export an org (UNAVAILABLE via token auth — session-only on the CP)", + Args: cobra.MaximumNArgs(1), RunE: runOrgExport, } -func runOrgExport(_ *cobra.Command, args []string) error { - raw, err := cpClient().ExportOrg(args[0]) - if err != nil { - return fmt.Errorf("org export: %w", err) - } - return printRaw(raw) +func runOrgExport(_ *cobra.Command, _ []string) error { + // Same story as org get: GET /api/v1/orgs/:slug/export is session-only on + // the control plane with no AdminGate-reachable equivalent, so a + // token-authenticated CLI cannot serve it. Fail fast. + return &exitError{code: 2, msg: "org export is not available to token-authenticated callers: the control plane gates GET /api/v1/orgs/:slug/export behind a WorkOS browser session, and no CP-admin bearer route exists for it. Use the dashboard."} } // =========================================================================== diff --git a/internal/cmd/org_test.go b/internal/cmd/org_test.go new file mode 100644 index 0000000..c320139 --- /dev/null +++ b/internal/cmd/org_test.go @@ -0,0 +1,171 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// orgTestServer spins up an httptest server that records the path, method, and +// the two credential headers of the request it receives, then returns the +// supplied body. Used to assert the org-lifecycle verbs hit the CP ADMIN +// surface with the CP-admin bearer (and NOT the tenant org key/org-id header). +type capturedReq struct { + method, path, auth, orgID string + hadAuth bool +} + +func orgTestServer(t *testing.T, body string) (*httptest.Server, *capturedReq) { + t.Helper() + got := &capturedReq{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got.method = r.Method + got.path = r.URL.Path + got.auth = r.Header.Get("Authorization") + got.orgID = r.Header.Get("X-Molecule-Org-Id") + _, got.hadAuth = r.Header["Authorization"] + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv, got +} + +// TestOrgList_TargetsAdminSurfaceWithCPAdminBearer asserts `org list` hits the +// control-plane ADMIN route (/api/v1/admin/orgs) authenticated with the +// CP-admin bearer (MOLECULE_CP_ADMIN_TOKEN) — and critically does NOT leak the +// tenant Org API Key (MOLECULE_API_KEY) or send the X-Molecule-Org-Id header. +func TestOrgList_TargetsAdminSurfaceWithCPAdminBearer(t *testing.T) { + srv, got := orgTestServer(t, `{"limit":100,"offset":0,"orgs":[{"slug":"acme","name":"Acme","plan":"pro","instance_status":"running","member_count":3}]}`) + + // Tenant org key is present in the env but must NOT be used for CP calls. + t.Setenv("MOLECULE_API_KEY", "org-key-SECRET") + t.Setenv("MOLECULE_ORG_ID", "org_should_not_leak") + t.Setenv("MOLECULE_CP_ADMIN_TOKEN", "cp-admin-bearer-123") + t.Setenv("MOLECULE_CP_URL", srv.URL) + + origFmt := outputFormat + defer func() { outputFormat = origFmt }() + outputFormat = "json" + + if err := runOrgList(nil, nil); err != nil { + t.Fatalf("runOrgList: %v", err) + } + if got.method != "GET" || got.path != "/api/v1/admin/orgs" { + t.Errorf("target = %s %s, want GET /api/v1/admin/orgs", got.method, got.path) + } + if got.auth != "Bearer cp-admin-bearer-123" { + t.Errorf("Authorization = %q, want CP-admin bearer", got.auth) + } + if strings.Contains(got.auth, "org-key-SECRET") { + t.Errorf("tenant Org API Key leaked to the control plane: %q", got.auth) + } + if got.orgID != "" { + t.Errorf("X-Molecule-Org-Id should not be sent to the CP admin surface, got %q", got.orgID) + } +} + +// TestOrgCreate_TargetsAdminSurfaceWithOwnerAndCPAdminBearer asserts `org +// create` POSTs to /api/v1/admin/orgs with the CP-admin bearer and the +// required owner_user_id, never the tenant org key. +func TestOrgCreate_TargetsAdminSurfaceWithOwnerAndCPAdminBearer(t *testing.T) { + srv, got := orgTestServer(t, `{"id":"o1","slug":"acme","name":"Acme","plan":"free","created_at":"now"}`) + + t.Setenv("MOLECULE_API_KEY", "org-key-SECRET") + t.Setenv("MOLECULE_ORG_ID", "org_should_not_leak") + t.Setenv("MOLECULE_CP_ADMIN_TOKEN", "cp-admin-bearer-123") + t.Setenv("MOLECULE_CP_URL", srv.URL) + + origFmt := outputFormat + defer func() { outputFormat = origFmt }() + outputFormat = "json" + + orgCreateFlags.slug = "acme" + orgCreateFlags.name = "Acme" + orgCreateFlags.ownerUserID = "user_123" + orgCreateFlags.template = "" + defer func() { orgCreateFlags.slug, orgCreateFlags.name, orgCreateFlags.ownerUserID = "", "", "" }() + + if err := runOrgCreate(nil, nil); err != nil { + t.Fatalf("runOrgCreate: %v", err) + } + if got.method != "POST" || got.path != "/api/v1/admin/orgs" { + t.Errorf("target = %s %s, want POST /api/v1/admin/orgs", got.method, got.path) + } + if got.auth != "Bearer cp-admin-bearer-123" { + t.Errorf("Authorization = %q, want CP-admin bearer", got.auth) + } + if got.orgID != "" { + t.Errorf("X-Molecule-Org-Id should not be sent to the CP admin surface, got %q", got.orgID) + } +} + +// TestOrgCreate_RequiresOwnerUserID confirms the admin create path fails fast +// (no network call) when --owner-user-id is missing. +func TestOrgCreate_RequiresOwnerUserID(t *testing.T) { + t.Setenv("MOLECULE_CP_ADMIN_TOKEN", "cp-admin-bearer-123") + orgCreateFlags.slug = "acme" + orgCreateFlags.name = "Acme" + orgCreateFlags.ownerUserID = "" + orgCreateFlags.template = "" + defer func() { orgCreateFlags.slug, orgCreateFlags.name = "", "" }() + + err := runOrgCreate(nil, nil) + if err == nil { + t.Fatal("expected error when --owner-user-id is missing") + } + if !strings.Contains(err.Error(), "owner-user-id") { + t.Errorf("error = %q, want it to mention owner-user-id", err.Error()) + } +} + +// TestOrgVerbs_FailFastWithoutCPAdminToken is the WRONG-CREDENTIAL path: when +// only the tenant Org API Key is set (no MOLECULE_CP_ADMIN_TOKEN), the +// CP-targeting verbs must fail fast with a clear message — NOT silently send +// the org key to the control plane and 401. +func TestOrgVerbs_FailFastWithoutCPAdminToken(t *testing.T) { + // Tenant key present, CP-admin token deliberately absent. + t.Setenv("MOLECULE_API_KEY", "org-key-SECRET") + t.Setenv("MOLECULE_ORG_ID", "org_abc") + t.Setenv("MOLECULE_CP_ADMIN_TOKEN", "") + // Point CP at an unreachable host so any accidental network call is a + // hard, obvious failure rather than a hang. + t.Setenv("MOLECULE_CP_URL", "http://127.0.0.1:0") + + // list + if err := runOrgList(nil, nil); err == nil { + t.Error("org list should fail fast without MOLECULE_CP_ADMIN_TOKEN") + } else if !strings.Contains(err.Error(), "MOLECULE_CP_ADMIN_TOKEN") { + t.Errorf("org list error = %q, want it to name MOLECULE_CP_ADMIN_TOKEN", err.Error()) + } + + // create + orgCreateFlags.slug = "acme" + orgCreateFlags.name = "Acme" + orgCreateFlags.ownerUserID = "user_123" + orgCreateFlags.template = "" + defer func() { orgCreateFlags.slug, orgCreateFlags.name, orgCreateFlags.ownerUserID = "", "", "" }() + if err := runOrgCreate(nil, nil); err == nil { + t.Error("org create should fail fast without MOLECULE_CP_ADMIN_TOKEN") + } else if !strings.Contains(err.Error(), "MOLECULE_CP_ADMIN_TOKEN") { + t.Errorf("org create error = %q, want it to name MOLECULE_CP_ADMIN_TOKEN", err.Error()) + } +} + +// TestOrgGetExport_FailFast confirms get/export do not pretend to work over +// token auth: they return a clear unavailable error instead of 401'ing the +// session-only CP routes. +func TestOrgGetExport_FailFast(t *testing.T) { + t.Setenv("MOLECULE_CP_ADMIN_TOKEN", "cp-admin-bearer-123") // even WITH the admin token + if err := runOrgGet(nil, []string{"acme"}); err == nil { + t.Error("org get should fail fast (session-only on the CP)") + } else if !strings.Contains(err.Error(), "session") { + t.Errorf("org get error = %q, want it to explain the session gate", err.Error()) + } + if err := runOrgExport(nil, []string{"acme"}); err == nil { + t.Error("org export should fail fast (session-only on the CP)") + } else if !strings.Contains(err.Error(), "session") { + t.Errorf("org export error = %q, want it to explain the session gate", err.Error()) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index fbfffa3..877535e 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -82,7 +82,11 @@ func Execute() error { viper.AutomaticEnv() _ = viper.ReadInConfig() // ignore not-found; env vars win - return rootCmd.Execute() + // rootCmd has SilenceErrors=true, so cobra prints nothing on error. + // Route the error through handleErr so user-facing messages (including + // exitError fail-fast guidance like the org-verb credential errors) are + // printed to stderr with the right exit code instead of being swallowed. + return handleErr(rootCmd.Execute()) } // envOr returns the value of env var key, or fallback if unset/empty. @@ -111,6 +115,22 @@ func cpURL() string { return apiURL } +// cpAdminClient builds a Platform client for the CP ADMIN surface +// (/api/v1/admin/orgs). It authenticates with the dedicated CP-admin bearer +// (MOLECULE_CP_ADMIN_TOKEN) and sends NO X-Molecule-Org-Id header — the admin +// routes are not org-scoped at the gate. Crucially it never carries the tenant +// Org API Key (MOLECULE_API_KEY), so the org credential is never leaked to the +// control plane. Returns an error when the admin token is unset so callers +// fail fast with a clear two-credential message instead of a bare 401. +func cpAdminClient() (*client.Platform, error) { + tok := cpAdminToken() + if tok == "" { + return nil, &exitError{code: 2, msg: "this verb hits the control-plane admin API and requires a CP admin bearer token in MOLECULE_CP_ADMIN_TOKEN (distinct from the tenant MOLECULE_API_KEY / Org API Key, which has no standing on the control plane). See `molecule org --help`."} + } + // OrgID is intentionally empty: AdminGate does not route on it. + return client.NewWithAuth(cpURL(), tok, ""), nil +} + // init registers all subcommand trees. func init() { rootCmd.AddCommand(workspaceCmd) diff --git a/internal/cmd/workspace_mgmt.go b/internal/cmd/workspace_mgmt.go index 3d0b980..1866b8c 100644 --- a/internal/cmd/workspace_mgmt.go +++ b/internal/cmd/workspace_mgmt.go @@ -94,10 +94,12 @@ func init() { const budgetUnset = -1 << 62 -func runWorkspaceBudget(cmd *cobra.Command, args []string) error { - cl := newClient() - id := args[0] - +// budgetLimitsFromFlags translates the four --hourly/--daily/--weekly/--monthly +// flags into the budget_limits map sent to PATCH /workspaces/:id/budget: +// an unset flag (budgetUnset) is omitted; a negative value clears that period +// (nil); a non-negative value sets the limit. Shared by prod and tests so the +// translation logic has exactly one definition. +func budgetLimitsFromFlags(hourly, daily, weekly, monthly int64) map[string]*int64 { limits := map[string]*int64{} add := func(period string, v int64) { if v == budgetUnset { @@ -110,10 +112,35 @@ func runWorkspaceBudget(cmd *cobra.Command, args []string) error { vv := v limits[period] = &vv } - add("hourly", budgetFlags.hourly) - add("daily", budgetFlags.daily) - add("weekly", budgetFlags.weekly) - add("monthly", budgetFlags.monthly) + add("hourly", hourly) + add("daily", daily) + add("weekly", weekly) + add("monthly", monthly) + return limits +} + +// resolveBillingMode validates a billing-mode argument and returns the value +// to send to the tenant: the three real modes pass through; clear/null/"" map +// to "" (clear the override); anything else is an error. Shared by prod and +// tests. +func resolveBillingMode(in string) (string, error) { + switch in { + case "platform_managed", "byok", "disabled": + return in, nil + case "clear", "null", "": + return "", nil + default: + return "", &exitError{code: 2, msg: "workspace billing-mode: mode must be platform_managed, byok, disabled, or clear"} + } +} + +func runWorkspaceBudget(cmd *cobra.Command, args []string) error { + cl := newClient() + id := args[0] + + limits := budgetLimitsFromFlags( + budgetFlags.hourly, budgetFlags.daily, budgetFlags.weekly, budgetFlags.monthly, + ) if len(limits) == 0 { // Show current budget. @@ -141,14 +168,10 @@ var workspaceBillingModeCmd = &cobra.Command{ } func runWorkspaceBillingMode(_ *cobra.Command, args []string) error { - id, mode := args[0], args[1] - switch mode { - case "platform_managed", "byok", "disabled": - // ok - case "clear", "null", "": - mode = "" // clear override - default: - return &exitError{code: 2, msg: "workspace billing-mode: mode must be platform_managed, byok, disabled, or clear"} + id := args[0] + mode, err := resolveBillingMode(args[1]) + if err != nil { + return err } raw, err := newClient().SetBillingMode(id, mode) if err != nil { -- 2.52.0 From 0ec3db81e6a2741c52c052aeda3fe4d16f969238 Mon Sep 17 00:00:00 2001 From: sdk-dev Date: Sun, 31 May 2026 23:44:52 -0700 Subject: [PATCH 3/4] =?UTF-8?q?fix(cli):=20address=20CR2=20review=20on=20#?= =?UTF-8?q?13=20=E2=80=94=20path-escaping,=20config=20binding,=20CP-admin?= =?UTF-8?q?=20targeting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/client/management.go | 5 +- internal/client/management_test.go | 67 +++++++++++++++++ internal/client/platform.go | 15 ++-- internal/cmd/agent.go | 3 +- internal/cmd/config.go | 9 ++- internal/cmd/management_test.go | 116 +++++++++++++++++++++++++++-- internal/cmd/org.go | 10 ++- internal/cmd/root.go | 57 +++++++++++--- internal/cmd/workspace.go | 3 +- 9 files changed, 257 insertions(+), 28 deletions(-) diff --git a/internal/client/management.go b/internal/client/management.go index 549787a..95e7fe0 100644 --- a/internal/client/management.go +++ b/internal/client/management.go @@ -204,7 +204,10 @@ type MintWorkspaceTokenResponse struct { // (POST /workspaces/:id/tokens). Plaintext is returned exactly once. func (p *Platform) MintWorkspaceToken(id string) (*MintWorkspaceTokenResponse, error) { var out MintWorkspaceTokenResponse - if err := p.postInto("/workspaces/"+url.PathEscape(id)+"/tokens", nil, &out); err != nil { + // Send an empty JSON object ({}) rather than a nil body: postInto would + // marshal nil to the literal `null`, which a handler that decodes into a + // struct/map can reject. {} matches sibling tooling and is always safe. + if err := p.postInto("/workspaces/"+url.PathEscape(id)+"/tokens", struct{}{}, &out); err != nil { return nil, err } return &out, nil diff --git a/internal/client/management_test.go b/internal/client/management_test.go index 9dcc93f..3ba5925 100644 --- a/internal/client/management_test.go +++ b/internal/client/management_test.go @@ -58,6 +58,69 @@ func TestClientAuthHeaders(t *testing.T) { } } +// TestPathSegmentEscaping proves caller-supplied IDs are url.PathEscape'd into +// their path segment, so an ID containing '/', '?' or '#' cannot alter the +// endpoint, slip into the query, or open a fragment. Covers the platform.go + +// management.go methods that interpolate IDs. +func TestPathSegmentEscaping(t *testing.T) { + // An ID engineered to break naive concatenation: a slash to escape the + // segment, a '?' to start a bogus query, a '#' to start a fragment. + const evil = "ws/../admin?x=1#frag" + + cases := []struct { + name string + reply string + call func(p *Platform) error + wantPath string // decoded path the server must see (single segment intact) + }{ + {"GetWorkspace", `{}`, func(p *Platform) error { _, e := p.GetWorkspace(evil); return e }, "/workspaces/" + evil}, + {"RestartWorkspace", `{}`, func(p *Platform) error { return p.RestartWorkspace(evil) }, "/workspaces/" + evil + "/restart"}, + {"ListWorkspaceAgents", `[]`, func(p *Platform) error { _, e := p.ListWorkspaceAgents(evil); return e }, "/workspaces/" + evil + "/agents"}, + {"GetAgent", `{}`, func(p *Platform) error { _, e := p.GetAgent(evil); return e }, "/agents/" + evil}, + {"GetPeers", `[]`, func(p *Platform) error { _, e := p.GetPeers(evil); return e }, "/registry/" + evil + "/peers"}, + {"GetDelegations", `[]`, func(p *Platform) error { _, e := p.GetDelegations(evil); return e }, "/workspaces/" + evil + "/delegations"}, + {"PauseWorkspace", `{}`, func(p *Platform) error { return p.PauseWorkspace(evil) }, "/workspaces/" + evil + "/pause"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var cap capture + srv := newCaptureServer(t, &cap, tc.reply) + defer srv.Close() + p := NewWithAuth(srv.URL, "k", "o") + if err := tc.call(p); err != nil { + t.Fatalf("call: %v", err) + } + // net/http decodes the escaped path back to the original segment, + // so the server sees the full ID as one intact path — not split by + // '/', and with '?'/'#' NOT promoted to query/fragment. + if cap.path != tc.wantPath { + t.Errorf("decoded path = %q, want %q", cap.path, tc.wantPath) + } + if cap.query != "" { + t.Errorf("query = %q, want empty (ID '?' must not leak into the query)", cap.query) + } + }) + } + + // DeleteWorkspace appends its own ?confirm=true; the escaped ID must not + // inject extra query params. + t.Run("DeleteWorkspace", func(t *testing.T) { + var cap capture + srv := newCaptureServer(t, &cap, `{}`) + defer srv.Close() + p := NewWithAuth(srv.URL, "k", "o") + if err := p.DeleteWorkspace(evil); err != nil { + t.Fatalf("DeleteWorkspace: %v", err) + } + if cap.path != "/workspaces/"+evil { + t.Errorf("decoded path = %q, want %q", cap.path, "/workspaces/"+evil) + } + if cap.query != "confirm=true" { + t.Errorf("query = %q, want exactly confirm=true (ID must not inject params)", cap.query) + } + }) +} + // TestManagementRequestConstruction is the table-driven proof that each new // management verb builds the right method, path, and body. The handler shapes // are aligned to the live workspace-server / controlplane handlers. @@ -190,11 +253,15 @@ func TestManagementRequestConstruction(t *testing.T) { wantBody: `{"mode":null}`, }, { + // Body must be an empty JSON object ({}), never the literal `null` + // that a nil body would marshal to (a struct/map handler can reject + // null). {} matches sibling tooling. name: "MintWorkspaceToken", reply: `{"auth_token":"x","workspace_id":"ws_1"}`, call: func(p *Platform) error { _, e := p.MintWorkspaceToken("ws_1"); return e }, wantMethod: "POST", wantPath: "/workspaces/ws_1/tokens", + wantBody: `{}`, }, { name: "ListWorkspaceSecrets", diff --git a/internal/client/platform.go b/internal/client/platform.go index 851dd35..9faceb2 100644 --- a/internal/client/platform.go +++ b/internal/client/platform.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "time" ) @@ -123,7 +124,7 @@ func (p *Platform) ListWorkspaces() ([]Workspace, error) { // GetWorkspace returns a single workspace by ID. func (p *Platform) GetWorkspace(id string) (*Workspace, error) { var out Workspace - if err := p.getInto(fmt.Sprintf("/workspaces/%s", id), &out); err != nil { + if err := p.getInto("/workspaces/"+url.PathEscape(id), &out); err != nil { return nil, err } return &out, nil @@ -140,13 +141,13 @@ func (p *Platform) CreateWorkspace(req CreateWorkspaceRequest) (*Workspace, erro // DeleteWorkspace deletes a workspace by ID. func (p *Platform) DeleteWorkspace(id string) error { - _, err := p.delete(fmt.Sprintf("/workspaces/%s?confirm=true", id)) + _, 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(fmt.Sprintf("/workspaces/%s/restart", id)) + _, err := p.postEmpty("/workspaces/" + url.PathEscape(id) + "/restart") return err } @@ -162,7 +163,7 @@ func (p *Platform) ListAgents() ([]Agent, error) { // ListWorkspaceAgents returns agents for a given workspace. func (p *Platform) ListWorkspaceAgents(workspaceID string) ([]Agent, error) { var out []Agent - if err := p.getInto(fmt.Sprintf("/workspaces/%s/agents", workspaceID), &out); err != nil { + if err := p.getInto("/workspaces/"+url.PathEscape(workspaceID)+"/agents", &out); err != nil { return nil, err } return out, nil @@ -171,7 +172,7 @@ func (p *Platform) ListWorkspaceAgents(workspaceID string) ([]Agent, error) { // GetAgent returns a single agent by ID. func (p *Platform) GetAgent(id string) (*Agent, error) { var out Agent - if err := p.getInto(fmt.Sprintf("/agents/%s", id), &out); err != nil { + if err := p.getInto("/agents/"+url.PathEscape(id), &out); err != nil { return nil, err } return &out, nil @@ -202,7 +203,7 @@ func (p *Platform) AuditWorkspaces() ([]Workspace, []Agent, error) { // GetPeers returns peer workspaces reachable from a workspace. func (p *Platform) GetPeers(workspaceID string) ([]Agent, error) { var out []Agent - if err := p.getInto(fmt.Sprintf("/registry/%s/peers", workspaceID), &out); err != nil { + if err := p.getInto("/registry/"+url.PathEscape(workspaceID)+"/peers", &out); err != nil { return nil, err } return out, nil @@ -211,7 +212,7 @@ func (p *Platform) GetPeers(workspaceID string) ([]Agent, error) { // 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(fmt.Sprintf("/workspaces/%s/delegations", workspaceID), &out); err != nil { + if err := p.getInto("/workspaces/"+url.PathEscape(workspaceID)+"/delegations", &out); err != nil { return nil, err } return out, nil diff --git a/internal/cmd/agent.go b/internal/cmd/agent.go index 11b9443..72f70a4 100644 --- a/internal/cmd/agent.go +++ b/internal/cmd/agent.go @@ -4,6 +4,7 @@ package cmd import ( "encoding/json" "fmt" + "net/url" "os" "text/tabwriter" @@ -133,7 +134,7 @@ func runAgentSend(cmd *cobra.Command, args []string) error { Error string `json:"error,omitempty"` } encoded, _ := json.Marshal(a2aReq{AgentID: agentID, Message: message}) - body, err := runHTTP("POST", cl.BaseURL+"/workspaces/"+wsID+"/a2a", encoded) + body, err := runHTTP("POST", cl.BaseURL+"/workspaces/"+url.PathEscape(wsID)+"/a2a", encoded) if err != nil { return fmt.Errorf("agent send: %w", err) } diff --git a/internal/cmd/config.go b/internal/cmd/config.go index 3748a87..3da2599 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -96,6 +96,13 @@ func runConfigSet(cmd *cobra.Command, args []string) error { if err != nil { configDir = "." } + // Ensure the config dir exists before writing — on a fresh machine + // ~/.config (or the platform equivalent) may not exist yet, and both + // WriteConfig and SafeWriteConfig fail with "no such file or directory" + // rather than creating it. + if mkErr := os.MkdirAll(configDir, 0o755); mkErr != nil { + return fmt.Errorf("config set: create config dir %s: %w", configDir, mkErr) + } configFile := filepath.Join(configDir, "molecule.yaml") v := viper.New() @@ -172,4 +179,4 @@ func runConfigView(cmd *cobra.Command, _ []string) error { fmt.Printf("# Config file: %s\n\n", viper.ConfigFileUsed()) fmt.Print(string(data)) return nil -} \ No newline at end of file +} diff --git a/internal/cmd/management_test.go b/internal/cmd/management_test.go index 478564f..b176d51 100644 --- a/internal/cmd/management_test.go +++ b/internal/cmd/management_test.go @@ -4,6 +4,8 @@ import ( "os" "path/filepath" "testing" + + "github.com/spf13/viper" ) // TestBudgetFlagMapping verifies the budget flag→limits translation: @@ -115,19 +117,123 @@ func TestAuthHelpers(t *testing.T) { } } -// TestCPURLFallback confirms cpURL falls back to apiURL when MOLECULE_CP_URL -// is unset, and prefers MOLECULE_CP_URL when set. -func TestCPURLFallback(t *testing.T) { +// TestApplyConfigDefaults confirms a config-file api_url/output value reaches +// the flag-backed globals (so `config set api_url` actually affects newClient), +// while an env var or an already-overridden global wins over the config file. +func TestApplyConfigDefaults(t *testing.T) { + origAPI, origFmt := apiURL, outputFormat + defer func() { + apiURL, outputFormat = origAPI, origFmt + viper.Reset() + }() + + // 1) Config file provides api_url + output; no env, globals at default → + // config values are adopted. + viper.Reset() + viper.Set("api_url", "https://cfg.example") + viper.Set("output", "json") + t.Setenv("MOLECULE_API_URL", "") + t.Setenv("MOL_OUTPUT", "") + apiURL = "http://localhost:8080" // untouched flag default + outputFormat = "table" // untouched flag default + applyConfigDefaults() + if apiURL != "https://cfg.example" { + t.Errorf("apiURL = %q, want config value (config set api_url must flow to the client)", apiURL) + } + if outputFormat != "json" { + t.Errorf("outputFormat = %q, want config value", outputFormat) + } + + // 2) Env var present → env wins, config file ignored. + viper.Reset() + viper.Set("api_url", "https://cfg.example") + t.Setenv("MOLECULE_API_URL", "https://env.example") + apiURL = "https://env.example" // flag default already folded the env in + applyConfigDefaults() + if apiURL != "https://env.example" { + t.Errorf("apiURL = %q, want env value (env must win over config file)", apiURL) + } + + // 3) Global already overridden away from the default (e.g. explicit flag) + // → config file does not clobber it. + viper.Reset() + viper.Set("api_url", "https://cfg.example") + t.Setenv("MOLECULE_API_URL", "") + apiURL = "https://flag.example" + applyConfigDefaults() + if apiURL != "https://flag.example" { + t.Errorf("apiURL = %q, want flag value (explicit flag must win over config file)", apiURL) + } +} + +// TestConfigSetMkdirsConfigDir confirms `config set` creates a missing config +// dir before writing (SafeWriteConfig/WriteConfig fail on a nonexistent dir). +func TestConfigSetMkdirsConfigDir(t *testing.T) { + // Point os.UserConfigDir at a temp HOME whose ~/.config does NOT exist yet. + tmp := t.TempDir() + t.Setenv("HOME", tmp) // darwin/linux + t.Setenv("XDG_CONFIG_HOME", "") // force ~/.config derivation on linux + t.Setenv("AppData", filepath.Join(tmp, "AppData", "Roaming")) // windows + + if err := runConfigSet(nil, []string{"api_url", "https://written.example"}); err != nil { + t.Fatalf("runConfigSet on missing config dir: %v", err) + } + cd, _ := os.UserConfigDir() + if _, err := os.Stat(filepath.Join(cd, "molecule.yaml")); err != nil { + t.Errorf("config file not written: %v", err) + } +} + +// TestCPURLNoTenantFallback confirms cpURL does NOT fall back to apiURL when +// MOLECULE_CP_URL is unset (it returns "" so the admin client can refuse to +// send the CP-admin bearer to a tenant host), and returns MOLECULE_CP_URL when +// set. +func TestCPURLNoTenantFallback(t *testing.T) { origAPI := apiURL defer func() { apiURL = origAPI }() apiURL = "https://tenant.example" t.Setenv("MOLECULE_CP_URL", "") - if got := cpURL(); got != "https://tenant.example" { - t.Errorf("cpURL fallback = %q, want tenant url", got) + if got := cpURL(); got != "" { + t.Errorf("cpURL with MOLECULE_CP_URL unset = %q, want \"\" (no tenant fallback)", got) } t.Setenv("MOLECULE_CP_URL", "https://api.moleculesai.app") if got := cpURL(); got != "https://api.moleculesai.app" { t.Errorf("cpURL = %q, want CP url", got) } } + +// TestCPAdminClientCredentialTargeting confirms the CP-admin client never sends +// the CP-admin bearer to the tenant apiURL: it requires an explicit +// MOLECULE_CP_URL and fails fast otherwise, even when MOLECULE_CP_ADMIN_TOKEN +// is set. +func TestCPAdminClientCredentialTargeting(t *testing.T) { + origAPI := apiURL + defer func() { apiURL = origAPI }() + apiURL = "https://tenant.example" + + // Token present but no CP URL → must fail fast (NOT target the tenant). + t.Setenv("MOLECULE_CP_ADMIN_TOKEN", "cp-admin-secret") + t.Setenv("MOLECULE_CP_URL", "") + if _, err := cpAdminClient(); err == nil { + t.Fatal("cpAdminClient with no MOLECULE_CP_URL should fail fast, not target the tenant host") + } + + // Token + explicit CP URL → client points at the CP, never the tenant. + t.Setenv("MOLECULE_CP_URL", "https://api.moleculesai.app") + cp, err := cpAdminClient() + if err != nil { + t.Fatalf("cpAdminClient with CP URL set: %v", err) + } + if cp.BaseURL != "https://api.moleculesai.app" { + t.Errorf("cpAdminClient BaseURL = %q, want CP url (must not be the tenant %q)", cp.BaseURL, apiURL) + } + if cp.BaseURL == apiURL { + t.Errorf("cpAdminClient targeted the tenant apiURL %q — CP-admin bearer would leak", apiURL) + } + // Missing token → fail fast regardless of CP URL. + t.Setenv("MOLECULE_CP_ADMIN_TOKEN", "") + if _, err := cpAdminClient(); err == nil { + t.Fatal("cpAdminClient with no MOLECULE_CP_ADMIN_TOKEN should fail fast") + } +} diff --git a/internal/cmd/org.go b/internal/cmd/org.go index 1cc6601..a4212ed 100644 --- a/internal/cmd/org.go +++ b/internal/cmd/org.go @@ -16,8 +16,10 @@ import ( // TWO credentials, two surfaces — do not conflate them: // // - Org-LIFECYCLE verbs (create / list) talk to the CONTROL-PLANE ADMIN API -// (MOLECULE_CP_URL, default = api-url) and need a CP-ADMIN bearer in -// MOLECULE_CP_ADMIN_TOKEN. The customer-facing /api/v1/orgs* routes are +// (MOLECULE_CP_URL — REQUIRED, no tenant-host fallback) and need a CP-ADMIN +// bearer in MOLECULE_CP_ADMIN_TOKEN. MOLECULE_CP_URL is intentionally not +// defaulted to --api-url so the privileged CP-admin bearer is never sent to +// a tenant host. The customer-facing /api/v1/orgs* routes are // WorkOS-session-gated (RequireSession) and cannot be reached by any // bearer-token CLI; the admin routes (/api/v1/admin/orgs, AdminGate) can. // The tenant Org API Key is NEVER sent to the control plane. @@ -39,7 +41,9 @@ Org-lifecycle verbs (create, list) use the CONTROL-PLANE ADMIN API and require a CP-admin bearer token — a credential DISTINCT from the tenant Org API Key: MOLECULE_CP_ADMIN_TOKEN CP admin bearer (org create/list) - MOLECULE_CP_URL CP base URL (default: --api-url) + MOLECULE_CP_URL CP base URL (required; e.g. https://api.moleculesai.app — + NOT defaulted to --api-url, to keep the CP-admin + bearer off tenant hosts) The tenant Org API Key (MOLECULE_API_KEY / MOLECULE_ORG_ID) is used ONLY for the tenant-scoped sub-verbs (create --template, token *, allowlist) and is diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 877535e..4cbeb77 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -82,6 +82,11 @@ func Execute() error { viper.AutomaticEnv() _ = viper.ReadInConfig() // ignore not-found; env vars win + // Fold config-file values into the globals the client reads (see + // applyConfigDefaults). Without this, `molecule config set api_url …` lands + // only in viper and never reaches newClient()/cpURL(). + applyConfigDefaults() + // rootCmd has SilenceErrors=true, so cobra prints nothing on error. // Route the error through handleErr so user-facing messages (including // exitError fail-fast guidance like the org-verb credential errors) are @@ -89,6 +94,30 @@ func Execute() error { return handleErr(rootCmd.Execute()) } +// applyConfigDefaults folds config-file values (read into viper) into the +// flag-backed globals that the client actually reads (apiURL, outputFormat). +// +// The cobra flags drive these globals, but a value written by +// `molecule config set api_url …` only lands in viper. Precedence we want: +// explicit --flag > MOLECULE_API_URL / MOL_OUTPUT env > config file > built-in +// default. The flag default already folds in the env var, and an explicit flag +// is applied by cobra during rootCmd.Execute() (after this runs), so here we +// only adopt the config value when the global is still at its untouched +// built-in default and no env override is present — making the config file the +// next source after env, and below an explicit flag. +func applyConfigDefaults() { + if os.Getenv("MOLECULE_API_URL") == "" && apiURL == "http://localhost:8080" { + if v := viper.GetString("api_url"); v != "" { + apiURL = v + } + } + if os.Getenv("MOL_OUTPUT") == "" && outputFormat == "table" { + if v := viper.GetString("output"); v != "" { + outputFormat = v + } + } +} + // envOr returns the value of env var key, or fallback if unset/empty. func envOr(key, fallback string) string { if v := os.Getenv(key); v != "" { @@ -104,15 +133,18 @@ func newClient() *client.Platform { return client.NewWithAuth(apiURL, authToken(), orgID()) } -// cpURL is the control-plane base URL for org-lifecycle verbs (org -// list/get/create/export). Org ops live on the CP (api.moleculesai.app), -// not the per-org tenant host. Defaults to the tenant api-url when unset so -// a combined dev host still works. +// cpURL returns the explicitly-configured control-plane base URL for +// org-lifecycle verbs (org list/create), or "" when MOLECULE_CP_URL is unset. +// +// Org ops live on the CP (api.moleculesai.app), authenticated with the +// privileged CP-admin bearer (MOLECULE_CP_ADMIN_TOKEN). We deliberately do NOT +// fall back to the tenant api-url: silently pointing the CP-admin surface at a +// tenant host would send that privileged bearer to a host that has no business +// seeing it (a self-host / customer tenant). Callers must require a non-empty +// value before sending the admin token. A combined dev host still works — set +// MOLECULE_CP_URL explicitly (it may equal MOLECULE_API_URL). func cpURL() string { - if v := os.Getenv("MOLECULE_CP_URL"); v != "" { - return v - } - return apiURL + return os.Getenv("MOLECULE_CP_URL") } // cpAdminClient builds a Platform client for the CP ADMIN surface @@ -127,8 +159,15 @@ func cpAdminClient() (*client.Platform, error) { if tok == "" { return nil, &exitError{code: 2, msg: "this verb hits the control-plane admin API and requires a CP admin bearer token in MOLECULE_CP_ADMIN_TOKEN (distinct from the tenant MOLECULE_API_KEY / Org API Key, which has no standing on the control plane). See `molecule org --help`."} } + // Require an explicit CP URL: never send the privileged CP-admin bearer to + // the tenant api-url. Sending MOLECULE_CP_ADMIN_TOKEN to a tenant host (which + // may be customer-controlled) would leak a control-plane credential. + base := cpURL() + if base == "" { + return nil, &exitError{code: 2, msg: "this verb hits the control-plane admin API and requires the CP base URL in MOLECULE_CP_URL (e.g. https://api.moleculesai.app). It is intentionally NOT defaulted to MOLECULE_API_URL so the CP-admin bearer is never sent to a tenant host. See `molecule org --help`."} + } // OrgID is intentionally empty: AdminGate does not route on it. - return client.NewWithAuth(cpURL(), tok, ""), nil + return client.NewWithAuth(base, tok, ""), nil } // init registers all subcommand trees. diff --git a/internal/cmd/workspace.go b/internal/cmd/workspace.go index a058891..4f2ae79 100644 --- a/internal/cmd/workspace.go +++ b/internal/cmd/workspace.go @@ -4,6 +4,7 @@ package cmd import ( "encoding/json" "fmt" + "net/url" "os" "text/tabwriter" @@ -286,7 +287,7 @@ func runWorkspaceDelegate(cmd *cobra.Command, args []string) error { Status string `json:"status,omitempty"` } encoded, _ := json.Marshal(delReq{TargetID: targetID, Task: task}) - body, err := runHTTP("POST", cl.BaseURL+"/workspaces/"+workspaceID+"/delegate", encoded) + body, err := runHTTP("POST", cl.BaseURL+"/workspaces/"+url.PathEscape(workspaceID)+"/delegate", encoded) if err != nil { return fmt.Errorf("workspace delegate: %w", err) } -- 2.52.0 From e878dca78b5b0c919988b399519f4ae7079f0739 Mon Sep 17 00:00:00 2001 From: sdk-dev Date: Mon, 1 Jun 2026 01:26:48 -0700 Subject: [PATCH 4/4] docs(client): fix misleading auth-tier package comment (CR cleanup #13) The internal/client package comment said CP org calls "use the same key/CP-admin bearer", which implied the tenant Org API Key could be sent to the control plane. The code (cpAdminClient) uses a DISTINCT CP-admin bearer (MOLECULE_CP_ADMIN_TOKEN) and never sends the tenant key to the CP; align the comment to match the detailed doc-comment in the orgs section and the implementation. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/client/management.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/client/management.go b/internal/client/management.go index 95e7fe0..f1c47e5 100644 --- a/internal/client/management.go +++ b/internal/client/management.go @@ -4,7 +4,8 @@ // Request/response shapes are aligned to the live workspace-server handlers // (tenant) and controlplane handlers (CP). Auth tiers per // PLATFORM-MANAGEMENT-API.md §1: tenant calls use the Org API Key as a bearer -// (+ X-Molecule-Org-Id); CP org calls use the same key/CP-admin bearer. +// (+ X-Molecule-Org-Id); CP org calls use a DISTINCT CP-admin bearer +// (MOLECULE_CP_ADMIN_TOKEN) — the tenant Org API Key is never sent to the CP. package client import ( -- 2.52.0