From 8d66c66968071ecccb4f0b8524c46b7df6247f85 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Wed, 26 Aug 2026 23:01:36 +0000 Subject: [PATCH] feat(cli): manage workspaces and hosted onboarding links from strix cloud --- AGENTS.md | 1 + README.md | 14 ++ docs/integrations/coding-agents.mdx | 2 +- pyproject.toml | 1 + skills/managed-pentesting-with-strix/SKILL.md | 26 ++++ strix/interface/cloud/__init__.py | 5 + strix/interface/cloud/runner.py | 27 ++++ strix/interface/cloud/spec.py | 44 ++++++- strix/interface/cloud/workspaces.py | 123 ++++++++++++++++++ tests/test_cloud_cli.py | 93 ++++++++++++- 10 files changed, 333 insertions(+), 3 deletions(-) create mode 100644 strix/interface/cloud/workspaces.py diff --git a/AGENTS.md b/AGENTS.md index 96e38080..66b222d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ Target-specific workflows built on the same engine: strix cloud vulns list --severity critical strix cloud billing topup --credits 20 # buy credits when a scan returns exit code 5 ``` + - Account setup runs from the CLI too: `strix cloud workspaces list|create|use`, `strix cloud org members invite`, `strix cloud billing subscribe --plan strix_pro`, `strix cloud billing portal`, `strix cloud integrations install github`, and `strix cloud domains verify `. The last four end at a person: the command prints a link or a DNS record for the user to open or add, and it never completes the payment, the installation, or the DNS change for them. - Every REST operation has a `strix cloud ` command. Run `strix cloud` to list them. Output is JSON when stdout is not a terminal (or with `--json`), and there are no prompts without a TTY. Exit codes: `0` success, `1` error, `2` usage, `4` auth or plan limit, `5` payment required. `--token` or `STRIX_API_TOKEN` overrides the stored sign-in. `--data` adds extra request fields as JSON, and accepts `@file` or `-` for standard input. - The REST API works directly too: https://docs.app.strix.ai (OpenAPI: https://docs.app.strix.ai/openapi.json). diff --git a/README.md b/README.md index 1ed3c3ea..6b6d15f7 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,20 @@ strix cloud credits # credit balance strix cloud billing topup --credits 20 # buy credits (agent payment, HTTP 402) ``` +Workspaces and account setup also work from the terminal: + +```bash +strix cloud workspaces list # your workspaces +strix cloud workspaces create --name "My Team" +strix cloud workspaces use "My Team" # store a token for another workspace +strix cloud billing subscribe --plan strix_pro # opens the hosted checkout page +strix cloud billing portal # opens the billing portal +strix cloud integrations install github # opens the app installation page +strix cloud domains verify # prints the DNS record to add +``` + +The last four commands end at a person. Strix creates the link, opens the browser for an interactive terminal, and always prints the URL. The user enters the card, approves the installation, or adds the DNS record. Pass `--no-browser` to print the URL only. + The commands are agent friendly. Output is JSON when stdout is not a terminal or when you pass `--json`. There are no prompts when stdin is not a terminal. Exit codes: `0` success, `1` error, `2` invalid usage, `4` authentication required, `5` payment required. Set the token with `--token` or `STRIX_API_TOKEN` to skip the stored sign-in. Write commands take request fields as flags, and every write command also accepts one JSON object with `--data`: diff --git a/docs/integrations/coding-agents.mdx b/docs/integrations/coding-agents.mdx index f74f86f5..80f24597 100644 --- a/docs/integrations/coding-agents.mdx +++ b/docs/integrations/coding-agents.mdx @@ -43,7 +43,7 @@ Both use the same engine and produce the same validated findings and SARIF, so a Everything an agent needs is machine-readable: - **Headless CLI** — `strix -n` runs without the TUI and exits with `0` (clean), `1` (error), or `2` (vulnerabilities found). -- **Cloud CLI** — `strix cloud` prints JSON when stdout is not a terminal (or with `--json`), never prompts without a TTY, and exits with `0` (success), `1` (error), `2` (usage), `4` (authentication required), or `5` (payment required). Credit top-ups pay the Stripe machine-payment challenge with an agent wallet (`strix cloud billing topup --credits N --yes`). +- **Cloud CLI** — `strix cloud` prints JSON when stdout is not a terminal (or with `--json`), never prompts without a TTY, and exits with `0` (success), `1` (error), `2` (usage), `4` (authentication required), or `5` (payment required). Credit top-ups pay the Stripe machine-payment challenge with an agent wallet (`strix cloud billing topup --credits N --yes`). Account setup also runs from the CLI: `strix cloud workspaces list|create|use`, `strix cloud org members invite`, `strix cloud billing subscribe`, `strix cloud billing portal`, and `strix cloud integrations install github`. The last three print a hosted link the user opens to finish the payment or approve the installation. - **REST API** — the managed platform exposes a documented [OpenAPI](https://docs.app.strix.ai/openapi.json) at `https://app.strix.ai/api/v1` (scans, vulnerabilities, assets, PR reviews, schedules, webhooks) with bearer tokens and scopes. - **Structured results** — every run writes `vulnerabilities.json`, `vulnerabilities.csv`, `findings.sarif` (SARIF 2.1.0), and per-finding Markdown under `strix_runs//`; the cloud exposes the same as JSON plus SARIF export. - **Budget controls** — `--max-budget` and `--max-turns` give agents hard cost/time caps. diff --git a/pyproject.toml b/pyproject.toml index 72041a3b..be216b5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -229,6 +229,7 @@ ignore = [ # Test doubles use fixture tokens/passwords and match a callee signature whose # args they intentionally ignore. "tests/test_viewer_auth.py" = ["S105", "S106", "ARG001"] +"tests/test_cloud_cli.py" = ["S105", "ARG001"] "tests/test_codex_auth.py" = ["S105", "S106", "SLF001"] # Hatchling loads the build hook by path, not as an importable package. "scripts/tui_sidecar_hook.py" = ["INP001"] diff --git a/skills/managed-pentesting-with-strix/SKILL.md b/skills/managed-pentesting-with-strix/SKILL.md index 720c5827..261d6a55 100644 --- a/skills/managed-pentesting-with-strix/SKILL.md +++ b/skills/managed-pentesting-with-strix/SKILL.md @@ -81,6 +81,32 @@ strix cloud billing auto-topup update --enabled --topup-credits 20 --monthly-cap An omitted `--monthly-cap-credits` keeps the stored cap. Pass `--no-monthly-cap` to remove the cap. +### Workspaces and account setup + +Manage workspaces with a personal token from `strix cloud login`: + +```bash +strix cloud workspaces list # id, name, role, and the active one +strix cloud workspaces create --name "My Team" +strix cloud workspaces use "My Team" # store a token for that workspace +strix cloud org members invite --email dev@example.com --role analyst +``` + +`workspaces use` mints a new token for a workspace the user already belongs to, and the role in that workspace limits the scopes. Add `--scopes` to request a smaller set. + +### Handoffs a person must finish + +Four steps end at the user. The command creates the link or the record and prints it. Strix opens the browser only in an interactive terminal. Pass `--no-browser` to print the URL only. + +```bash +strix cloud billing subscribe --plan strix_pro # hosted checkout page for a plan +strix cloud billing portal # billing portal for the card and the plan +strix cloud integrations install github # GitHub App or Slack installation page +strix cloud domains verify # DNS record to add, then run it again +``` + +Give the printed URL or DNS record to the user and wait. Do not claim that the payment, the installation, or the DNS change is complete. Confirm the result afterwards with `strix cloud credits`, `strix cloud integrations list`, or `strix cloud domains list`. All four commands need an admin token, except `domains verify`, which needs `assets:write`. + ## 1. Register the target as an asset Scans run against **registered assets**, not raw URLs. Register once, then reuse the returned UUID. diff --git a/strix/interface/cloud/__init__.py b/strix/interface/cloud/__init__.py index 2f36542a..ce0b753d 100644 --- a/strix/interface/cloud/__init__.py +++ b/strix/interface/cloud/__init__.py @@ -12,6 +12,7 @@ from rich.console import Console from strix.interface.cloud.runner import resolve, run from strix.interface.cloud.spec import DEFAULT_VERBS, GROUP_HELP, SPEC +from strix.interface.cloud.workspaces import run_workspace_use from strix.interface.platform_cli import run_login @@ -45,6 +46,8 @@ def run_cloud(argv: list[str]) -> int: return run_login(session_argv[group]) if group == "credits": group, rest = "billing", ["credits", *rest] + if group == "workspaces" and rest and rest[0] == "use": + return run_workspace_use(rest[1:]) if group not in SPEC: console.print(f"[red]Unknown command:[/] {group}") @@ -70,3 +73,5 @@ def _print_verbs(console: Console, group: str) -> None: console.print(f"[bold]strix cloud {group}[/] verbs:") for verb, cmd in SPEC[group].items(): console.print(f" {verb:<28}{cmd.help}") + if group == "workspaces": + console.print(f" {'use':<28}Switch the stored token to another workspace.") diff --git a/strix/interface/cloud/runner.py b/strix/interface/cloud/runner.py index 54738fd5..7c400609 100644 --- a/strix/interface/cloud/runner.py +++ b/strix/interface/cloud/runner.py @@ -13,6 +13,7 @@ import shutil import subprocess import sys import time +import webbrowser from pathlib import Path from typing import Any, cast @@ -123,10 +124,30 @@ def _execute( result = _poll(console, path, token=token, as_json=as_json) elif cmd.wait_path: result = _wait(console, cmd, result, token=token, as_json=as_json) + if cmd.link: + return _handoff_link(console, cmd, args, result, as_json=as_json) emit(console, result, as_json=as_json) return http.EXIT_OK +def _handoff_link( + console: Console, cmd: Cmd, args: argparse.Namespace, result: Any, *, as_json: bool +) -> int: + """Print a hosted URL a person must open, and open the browser when interactive.""" + url = result.get(cmd.link) if isinstance(result, dict) else None + if not isinstance(url, str) or not url: + emit(console, result, as_json=as_json) + return http.EXIT_OK + interactive = sys.stdout.isatty() and not getattr(args, "no_browser", False) + if as_json: + emit(console, result, as_json=True) + else: + console.print(f"Open this URL to continue:\n [bold]{url}[/]") + if interactive: + webbrowser.open(url) + return http.EXIT_OK + + def _load_data(value: str) -> dict[str, Any]: """Read a JSON object from a literal string, a `@file` path, or `-` for stdin.""" if value == "-": @@ -179,6 +200,12 @@ def _build_parser(group: str, verb_label: str, cmd: Cmd) -> argparse.ArgumentPar ) if cmd.binary: parser.add_argument("--output", default=None, metavar="FILE", help="Write to this file.") + if cmd.link: + parser.add_argument( + "--no-browser", + action="store_true", + help="Do not open the browser. Print the URL only.", + ) if cmd.wait_path or cmd.wait_self: parser.add_argument( "--wait", action="store_true", help="Wait until the operation reaches a final state." diff --git a/strix/interface/cloud/spec.py b/strix/interface/cloud/spec.py index fcdef735..d26a1cd3 100644 --- a/strix/interface/cloud/spec.py +++ b/strix/interface/cloud/spec.py @@ -36,6 +36,10 @@ class Cmd: wait_path: str | None = None # When true, `--wait` polls GET on this same path until the status is final. wait_self: bool = False + # Response field that holds a URL a person must open, for example a hosted + # checkout page. The runner opens the browser for an interactive terminal + # and always prints the URL. + link: str | None = None def _q(*names: str) -> tuple[P, ...]: @@ -498,6 +502,27 @@ SPEC: dict[str, dict[str, Cmd]] = { "Buy credits with an agent payment (HTTP 402 flow).", body=(P("credits", "int", required=True, help="Number of credits to buy."),), ), + "subscribe": Cmd( + "POST", + "/billing/checkout", + "Create a checkout link for a plan or a credit pack. A person completes the payment.", + body=( + P( + "product", + required=True, + flag="plan", + help="Product to buy, for example strix_pro, strix_cloud, or strix_top_up.", + ), + P("success_url", help="Page to open after the payment."), + ), + link="checkout_url", + ), + "portal": Cmd( + "POST", + "/billing/portal", + "Create a billing portal link. A person manages the card and the plan there.", + link="portal_url", + ), "auto-topup": Cmd("GET", "/billing/auto-topup", "Get the automatic top-up settings."), "auto-topup update": Cmd( "PUT", @@ -709,6 +734,12 @@ SPEC: dict[str, dict[str, Cmd]] = { "Validate a Git provider token. The provider is gitlab or bitbucket.", body=_GIT_TOKEN_BODY, ), + "install": Cmd( + "POST", + "/integrations/{provider}/install-url", + "Create an installation link. The provider is github or slack. A person approves it.", + link="url", + ), "disconnect": Cmd("DELETE", "/integrations/{provider}", "Disconnect an integration."), }, "connectors": { @@ -852,11 +883,21 @@ SPEC: dict[str, dict[str, Cmd]] = { ), "delete": Cmd("DELETE", "/uploads/{uploadId}", "Delete an upload."), }, + "workspaces": { + "list": Cmd("GET", "/workspaces", "List the workspaces of your account."), + "create": Cmd( + "POST", + "/workspaces", + "Create a workspace and become its admin.", + body=(P("name", required=True, help="Workspace name."),), + ), + }, } # Default verbs let a bare group name run its most common read command. DEFAULT_VERBS: dict[str, str] = { + "workspaces": "list", "costs": "overview", "audit": "list", "license": "show", @@ -876,7 +917,8 @@ GROUP_HELP: dict[str, str] = { "chat": "Interactive pentest chat sessions", "knowledge": "Manage the knowledge base", "org": "Manage the organization and its members", - "integrations": "Connect Git providers", + "integrations": "Connect Git providers and other integrations", + "workspaces": "List, create, and switch workspaces", "connectors": "Manage network connectors", "webhooks": "Manage webhooks", "analytics": "Read analytics data", diff --git a/strix/interface/cloud/workspaces.py b/strix/interface/cloud/workspaces.py new file mode 100644 index 00000000..4958cd73 --- /dev/null +++ b/strix/interface/cloud/workspaces.py @@ -0,0 +1,123 @@ +"""`strix cloud workspaces use` — switch the stored token to another workspace. + +The command lists the workspaces of the account, finds the requested one by +ID or by exact name, asks the platform for a token in that workspace, and +stores the token in the credential file. The role of the account in the new +workspace limits the granted scopes. +""" + +from __future__ import annotations + +import argparse +from typing import Any, cast + +from rich.console import Console + +from strix.interface.cloud import http +from strix.interface.cloud.render import emit, json_mode +from strix.interface.platform_cli import AUTH_PATH, read_record, save_record + + +def run_workspace_use(argv: list[str]) -> int: + """Entry point for ``strix cloud workspaces use``. Returns an exit code.""" + console = Console() + parser = argparse.ArgumentParser( + prog="strix cloud workspaces use", + description="Switch the stored API token to another workspace.", + ) + parser.add_argument("workspace", metavar="WORKSPACE", help="Workspace ID or exact name.") + parser.add_argument( + "--scopes", + nargs="+", + metavar="SCOPE", + default=None, + help="API scopes for the new token. Without this option the server grants the defaults.", + ) + parser.add_argument("--json", action="store_true", help="Print the raw JSON response.") + parser.add_argument("--token", default=None, help="API token override.") + parser.add_argument("--app-url", default=None, metavar="URL", help="Platform URL override.") + parser.add_argument( + "--timeout", default=None, type=float, metavar="SECONDS", help="Request timeout in seconds." + ) + try: + args = parser.parse_args(argv) + except SystemExit as exc: + return exc.code if isinstance(exc.code, int) else 2 + + as_json = json_mode(flag=bool(args.json)) + http.configure(base_url=args.app_url, timeout=args.timeout) + try: + return _use(console, args, as_json=as_json) + except http.CloudError as exc: + if as_json: + emit(console, {"error": str(exc)}, as_json=True) + else: + console.print(f"[red]Error:[/] {exc}") + return exc.exit_code + + +def _use(console: Console, args: argparse.Namespace, *, as_json: bool) -> int: + workspace = _find_workspace(args.workspace, token=args.token) + body: dict[str, Any] = {} + if args.scopes: + body["scopes"] = args.scopes + minted = http.check( + http.request( + "POST", + f"/workspaces/{workspace['id']}/token", + token=args.token, + body=body or None, + ) + ) + if not isinstance(minted, dict) or not minted.get("api_token"): + raise http.CloudError("the platform did not return a token.") + minted_record = cast("dict[str, Any]", minted) + + record = read_record() or {} + record.update( + { + "api_token": minted_record["api_token"], + "organization_id": minted_record.get("organization_id", workspace["id"]), + "organization_name": minted_record.get("organization_name", workspace.get("name", "")), + "expires_at": minted_record.get("expires_at"), + "scopes": minted_record.get("scopes", []), + } + ) + if minted_record.get("email"): + record["email"] = minted_record["email"] + save_record(record) + + result = { + "workspace_id": record["organization_id"], + "workspace_name": record["organization_name"], + "scopes": record["scopes"], + } + if as_json: + emit(console, result, as_json=True) + return http.EXIT_OK + console.print(f"[green]✓ Switched to workspace [bold]{record['organization_name']}[/].[/]") + scopes = record.get("scopes") + if isinstance(scopes, list) and scopes: + console.print(f" Scopes: [dim]{' '.join(str(s) for s in scopes)}[/]") + console.print(f" Token: stored in [dim]{AUTH_PATH}[/]") + return http.EXIT_OK + + +def _find_workspace(selector: str, *, token: str | None) -> dict[str, Any]: + listed = http.check(http.request("GET", "/workspaces", token=token)) + items = listed.get("workspaces") if isinstance(listed, dict) else None + workspaces = [cast("dict[str, Any]", item) for item in (items or []) if isinstance(item, dict)] + if not workspaces: + raise http.CloudError("no workspaces found for this account.") + wanted = selector.strip() + by_id = [w for w in workspaces if w.get("id") == wanted] + if by_id: + return by_id[0] + by_name = [w for w in workspaces if str(w.get("name", "")).casefold() == wanted.casefold()] + if len(by_name) == 1: + return by_name[0] + if len(by_name) > 1: + ids = ", ".join(str(w.get("id")) for w in by_name) + raise http.CloudError(f"multiple workspaces are named {wanted!r}. Use an ID: {ids}") + names = ", ".join(str(w.get("name")) for w in workspaces) + raise http.CloudError(f"no workspace matches {wanted!r}. Your workspaces: {names}") diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py index 89c5f794..08501c35 100644 --- a/tests/test_cloud_cli.py +++ b/tests/test_cloud_cli.py @@ -4,13 +4,14 @@ from __future__ import annotations import io import json +import webbrowser from typing import TYPE_CHECKING, Any import pytest import requests from strix.interface import cloud, platform_cli -from strix.interface.cloud import http, render, runner +from strix.interface.cloud import http, render, runner, workspaces from strix.interface.cloud.spec import SPEC @@ -353,3 +354,93 @@ def test_created_id_reads_resource_id() -> None: assert runner._created_id({"scan_id": "abc", "status": "pending"}) == "abc" assert runner._created_id({"id": "xyz"}) == "xyz" assert runner._created_id({"status": "pending"}) is None + + +def test_billing_subscribe_prints_checkout_url( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + seen: dict[str, Any] = {} + + def fake_request(method: str, path: str, **kwargs: Any) -> FakeResponse: + seen["method"], seen["path"] = method, path + seen["body"] = kwargs.get("body") + return FakeResponse(status_code=200, payload={"checkout_url": "https://pay.test/session"}) + + monkeypatch.setattr(http, "request", fake_request) + code = cloud.run_cloud(["billing", "subscribe", "--plan", "strix_pro", "--json"]) + assert code == 0 + assert seen["method"] == "POST" + assert seen["path"] == "/billing/checkout" + assert seen["body"] == {"product": "strix_pro"} + assert "https://pay.test/session" in capsys.readouterr().out + + +def test_integration_install_url_does_not_open_browser( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + opened: list[str] = [] + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse(status_code=200, payload={"url": "https://github.test/app"}), + ) + + def fake_open(url: str) -> bool: + opened.append(url) + return True + + monkeypatch.setattr(webbrowser, "open", fake_open) + code = cloud.run_cloud(["integrations", "install", "github", "--json"]) + assert code == 0 + assert opened == [] + assert "https://github.test/app" in capsys.readouterr().out + + +def test_workspaces_use_switches_stored_token( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: Any +) -> None: + auth_path = tmp_path / "platform-auth.json" + monkeypatch.setattr(platform_cli, "AUTH_PATH", auth_path) + monkeypatch.setattr(workspaces, "AUTH_PATH", auth_path) + platform_cli.save_record({"api_token": "old", "email": "a@b.test"}) + + calls: list[tuple[str, str]] = [] + + def fake_request(method: str, path: str, **kwargs: Any) -> FakeResponse: + calls.append((method, path)) + if path == "/workspaces": + return FakeResponse( + status_code=200, + payload={"workspaces": [{"id": "org_1", "name": "Team One", "role": "admin"}]}, + ) + return FakeResponse( + status_code=201, + payload={ + "api_token": "new-token", + "organization_id": "org_1", + "organization_name": "Team One", + "scopes": ["scans:read"], + }, + ) + + monkeypatch.setattr(http, "request", fake_request) + code = cloud.run_cloud(["workspaces", "use", "team one", "--json"]) + assert code == 0 + assert calls == [("GET", "/workspaces"), ("POST", "/workspaces/org_1/token")] + record = platform_cli.read_record() + assert record is not None + assert record["api_token"] == "new-token" + assert record["organization_name"] == "Team One" + assert record["email"] == "a@b.test" + assert "org_1" in capsys.readouterr().out + + +def test_workspaces_use_reports_unknown_workspace(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + status_code=200, payload={"workspaces": [{"id": "org_1", "name": "Team One"}]} + ), + ) + assert cloud.run_cloud(["workspaces", "use", "missing", "--json"]) == 1