feat(cli): fix runHTTP auth bug + add management verbs #13
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user