feat(cli): fix runHTTP auth bug + add management verbs #13

Merged
devops-engineer merged 4 commits from feat/management-cli-verbs into main 2026-06-01 09:47:20 +00:00
19 changed files with 2500 additions and 52 deletions
+41
View File
@@ -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()
+390
View File
@@ -0,0 +1,390 @@
// 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 a DISTINCT CP-admin bearer
// (MOLECULE_CP_ADMIN_TOKEN) — the tenant Org API Key is never sent to the CP.
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
)
// ---------------------------------------------------------------------------
// 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 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,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"`
}
// 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 adminListOrgsResponse
if err := p.getInto("/api/v1/admin/orgs", &out); err != nil {
return nil, err
}
return out.Orgs, nil
}
// 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"`
OwnerUserID string `json:"owner_user_id"`
}
// 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/admin/orgs", req, &out); err != nil {
return nil, err
}
return &out, nil
}
// ---------------------------------------------------------------------------
// 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
// 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
}
// ---------------------------------------------------------------------------
// 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
}
+387
View File
@@ -0,0 +1,387 @@
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")
}
}
// 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.
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
}{
{
// 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: `{"limit":100,"offset":0,"orgs":[]}`,
call: func(p *Platform) error { _, e := p.ListOrgs(); return e },
wantMethod: "GET",
wantPath: "/api/v1/admin/orgs",
},
{
// 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/admin/orgs",
wantBody: `{"slug":"acme","name":"Acme","owner_user_id":"user_123"}`,
},
{
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}`,
},
{
// 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",
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)
}
})
}
}
+46 -10
View File
@@ -7,16 +7,25 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"time"
)
// 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 <Token>` on every request. Empty on a
// fresh self-host/dev tenant that doesn't enforce auth.
Token string
// OrgID satisfies the tenant's X-Molecule-Org-Id routing gate. Sent only
// when non-empty so single-tenant hosts keep working.
OrgID string
client *http.Client
}
// New returns a Platform client configured with baseURL.
// 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 +33,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"`
@@ -92,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
@@ -109,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
}
@@ -131,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
@@ -140,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
@@ -171,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
@@ -180,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
@@ -196,6 +228,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 +255,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 +279,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 +298,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 +309,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
}
}
+8 -7
View File
@@ -4,11 +4,12 @@ package cmd
import (
"encoding/json"
"fmt"
"net/url"
"os"
"text/tabwriter"
"go.moleculesai.app/cli/internal/client"
"github.com/spf13/cobra"
"go.moleculesai.app/cli/internal/client"
)
// ---------------------------------------------------------------------------
@@ -38,7 +39,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 +80,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 +114,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 {
@@ -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)
}
@@ -159,7 +160,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 +182,4 @@ func runAgentPeers(cmd *cobra.Command, args []string) error {
p.ID, p.Name, p.WorkspaceID, p.Status, p.Model)
}
return w.Flush()
}
}
+86
View File
@@ -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 <workspace-id> [--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 <workspace-id>",
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 <bundle.json|->",
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 <path|-> 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)
}
+8 -1
View File
@@ -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
}
}
+42
View File
@@ -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)
}
+54 -3
View File
@@ -4,19 +4,70 @@ 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")
}
// 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,
// 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 <key>`; the tenant's TenantGuard additionally
// requires `X-Molecule-Org-Id: <orgId>` 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 +82,4 @@ func runHTTP(method, url string, body []byte) ([]byte, error) {
func httpClient() *http.Client {
return &http.Client{Timeout: 30 * time.Second}
}
}
+87
View File
@@ -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")
}
}
+239
View File
@@ -0,0 +1,239 @@
package cmd
import (
"os"
"path/filepath"
"testing"
"github.com/spf13/viper"
)
// 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) {
// All unset → empty map (show, not set).
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 := budgetLimitsFromFlags(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 via
// the SHARED resolveBillingMode helper (same code prod runs).
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, 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 {
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())
}
}
// 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 != "" {
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")
}
}
+322
View File
@@ -0,0 +1,322 @@
// 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.
//
// TWO credentials, two surfaces — do not conflate them:
//
// - Org-LIFECYCLE verbs (create / list) talk to the CONTROL-PLANE ADMIN API
// (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.
//
// - 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.
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 (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
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() {
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 {
cp, err := cpAdminClient()
if err != nil {
return err
}
orgs, err := cp.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\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.InstanceStatus, o.MemberCount)
}
return w.Flush()
}
// ===========================================================================
// molecule org get <slug>
// ===========================================================================
var orgGetCmd = &cobra.Command{
Use: "get <slug>",
Short: "Show a single org (UNAVAILABLE via token auth — session-only on the CP)",
Args: cobra.MaximumNArgs(1),
RunE: runOrgGet,
}
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
ownerUserID string
template string
mode string
}
var orgCreateCmd = &cobra.Command{
Use: "create --slug <slug> --name <name> --owner-user-id <id> | --template <dir>",
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=<cp-admin-bearer> \
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) — 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 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")
}
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 admin org create (POST /api/v1/admin/orgs).
if orgCreateFlags.slug == "" || orgCreateFlags.name == "" {
return &exitError{code: 2, msg: "org create: provide --slug, --name and --owner-user-id (CP admin create), or --template (tenant org-from-template)"}
}
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)
}
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 <slug>
// ===========================================================================
var orgExportCmd = &cobra.Command{
Use: "export <slug>",
Short: "Export an org (UNAVAILABLE via token auth — session-only on the CP)",
Args: cobra.MaximumNArgs(1),
RunE: runOrgExport,
}
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."}
}
// ===========================================================================
// 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 <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 <token-id>",
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 [<org-id>]
// ===========================================================================
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)
}
+171
View File
@@ -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())
}
}
+11 -11
View File
@@ -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)
}
}
+128 -5
View File
@@ -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()}
})
@@ -71,7 +82,40 @@ func Execute() error {
viper.AutomaticEnv()
_ = viper.ReadInConfig() // ignore not-found; env vars win
return rootCmd.Execute()
// 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
// printed to stderr with the right exit code instead of being swallowed.
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.
@@ -82,6 +126,50 @@ 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 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 {
return os.Getenv("MOLECULE_CP_URL")
}
// 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`."}
}
// 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(base, tok, ""), nil
}
// init registers all subcommand trees.
func init() {
rootCmd.AddCommand(workspaceCmd)
@@ -90,10 +178,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 +214,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 +256,4 @@ func kv(w *tabwriter.Writer, k, v string) {
func versionInfo() string {
return fmt.Sprintf("molecule %s (go %s)", Version, runtime.Version())
}
}
+132
View File
@@ -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 <ws-id>, set <ws-id> <key> <value>, delete <ws-id> <key>}
// molecule secret org {list, set <key> <value>, delete <key>}
//
// 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 <workspace-id>",
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 <workspace-id> <key> <value>",
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 <workspace-id> <key>",
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 <key> <value>",
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 <key>",
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
}
+114
View File
@@ -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 <n> --file <k>=<path>..., 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 <name> --file <relpath>=<localpath> [--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)
}
+19 -15
View File
@@ -4,11 +4,12 @@ package cmd
import (
"encoding/json"
"fmt"
"net/url"
"os"
"text/tabwriter"
"go.moleculesai.app/cli/internal/client"
"github.com/spf13/cobra"
"go.moleculesai.app/cli/internal/client"
)
// ---------------------------------------------------------------------------
@@ -25,7 +26,10 @@ func init() {
workspaceCmd.AddCommand(
workspaceListCmd, workspaceCreateCmd, workspaceInspectCmd,
workspaceDeleteCmd, workspaceRestartCmd, workspaceAuditCmd, workspaceDelegateCmd,
workspaceGetCmd, workspacePauseCmd, workspaceResumeCmd,
workspaceBudgetCmd, workspaceBillingModeCmd, workspaceTokenCmd,
)
workspaceTokenCmd.AddCommand(workspaceTokenMintCmd)
}
// ===========================================================================
@@ -38,7 +42,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 +98,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 +143,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 +181,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 +200,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 +218,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 +276,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"`
@@ -283,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)
}
@@ -298,4 +302,4 @@ func runWorkspaceDelegate(cmd *cobra.Command, args []string) error {
}
_ = workspaceID
return nil
}
}
+215
View File
@@ -0,0 +1,215 @@
// 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 <id> (alias of inspect)
// ===========================================================================
var workspaceGetCmd = &cobra.Command{
Use: "get <workspace-id>",
Short: "Show full details for a workspace (alias of inspect)",
Args: cobra.ExactArgs(1),
RunE: runWorkspaceInspect,
}
// ===========================================================================
// molecule workspace pause <id>
// ===========================================================================
var workspacePauseCmd = &cobra.Command{
Use: "pause <workspace-id>",
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 <id>
// ===========================================================================
var workspaceResumeCmd = &cobra.Command{
Use: "resume <workspace-id>",
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 <id> [--hourly|--daily|--weekly|--monthly cents]
// ===========================================================================
var budgetFlags struct {
hourly int64
daily int64
weekly int64
monthly int64
}
var workspaceBudgetCmd = &cobra.Command{
Use: "budget <workspace-id> [--hourly|--daily|--weekly|--monthly <cents>]",
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
// 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 {
return
}
if v < 0 {
limits[period] = nil // clear
return
}
vv := v
limits[period] = &vv
}
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.
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 <id> <mode|clear>
// ===========================================================================
var workspaceBillingModeCmd = &cobra.Command{
Use: "billing-mode <workspace-id> <platform_managed|byok|disabled|clear>",
Short: "Set a workspace's LLM billing-mode override",
Args: cobra.ExactArgs(2),
RunE: runWorkspaceBillingMode,
}
func runWorkspaceBillingMode(_ *cobra.Command, args []string) error {
id := args[0]
mode, err := resolveBillingMode(args[1])
if err != nil {
return err
}
raw, err := newClient().SetBillingMode(id, mode)
if err != nil {
return fmt.Errorf("workspace billing-mode: %w", err)
}
return printRaw(raw)
}
// ===========================================================================
// molecule workspace token mint <id>
// ===========================================================================
var workspaceTokenCmd = &cobra.Command{
Use: "token",
Short: "Manage per-workspace auth tokens",
}
var workspaceTokenMintCmd = &cobra.Command{
Use: "mint <workspace-id>",
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
}