fix(cloud): align agent commands with API contracts

This commit is contained in:
bearsyankees 2026-08-27 15:53:56 -04:00
parent 3580412178
commit a02c56423c
8 changed files with 151 additions and 18 deletions

View file

@ -44,11 +44,11 @@ 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 <id>`. 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.
- Account setup runs from the CLI too: `strix cloud workspaces list|create|use`, `strix cloud org members invite`, `strix cloud billing subscribe --plan strix_cloud`, `strix cloud billing portal`, `strix cloud integrations install github`, and `strix cloud domains verify <id>`. 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 <resource> <verb>` 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).
- CLI docs index for LLMs: https://docs.strix.ai/llms.txt (full: https://docs.strix.ai/llms-full.txt).
- CLI docs index for LLMs: https://docs.strix.ai/llms.txt (full: https://docs.strix.ai/llms-full.txt). Managed API docs for LLMs: https://docs.app.strix.ai/llms.txt.
- Only scan targets the user is authorized to test.
## Contributing to this repo

View file

@ -350,7 +350,7 @@ Workspaces and account setup also work from the terminal:
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 subscribe --plan strix_cloud # 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 <domain-id> # prints the DNS record to add

View file

@ -14,7 +14,7 @@ Use this when you want Strix's autonomous pentesting **without running Docker or
There are two equivalent interfaces. Prefer the CLI:
- **`strix cloud` CLI** — every REST operation has a command in the form `strix cloud <resource> <verb>`. Install with `curl -sSL https://strix.ai/install | bash`. Run `strix cloud` to list all resources and `strix cloud <resource>` to list its verbs.
- **REST API** — base URL `https://app.strix.ai/api/v1`, `Authorization: Bearer <token>` on every request. Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · OpenAPI: `https://docs.app.strix.ai/openapi.json`.
- **REST API** — base URL `https://app.strix.ai/api/v1`, `Authorization: Bearer <token>` on every request. Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · agent index: `https://docs.app.strix.ai/llms.txt` · OpenAPI: `https://docs.app.strix.ai/openapi.json`.
The CLI is agent friendly. Output is JSON when stdout is not a terminal, or when you pass `--json`. There are no interactive prompts when stdin is not a terminal. Exit codes: `0` success, `1` error, `2` invalid usage, `4` authentication required, `5` payment required.
@ -38,7 +38,7 @@ strix cloud login --scopes scans:read scans:write billing:read vulnerabilities:r
The user approves the sign-in in the browser. With `--scopes` (and optionally `--workspace <name-or-id>`) there are no prompts, so the command works from a non-interactive agent shell. In an interactive terminal without flags, the CLI offers a workspace picker and scope presets (Recommended, Full access, Minimal, Custom).
- `strix cloud whoami` shows the active sign-in. `strix cloud logout` removes it.
- `strix cloud whoami` shows the active sign-in; add `--json` when another agent will parse it. `strix cloud logout` removes it.
- Every other `strix cloud` command uses the stored token automatically. To use a different token (for example one created in the dashboard at **Settings → API Access**), pass `--token <token>` or set `STRIX_API_TOKEN`.
- Never hardcode, log, or commit the token. Store it in an env var or the CI secret store.
- **Scopes (least-privilege):** assign only what the integration needs and rotate regularly:
@ -101,7 +101,7 @@ strix cloud org members invite --email dev@example.com --role analyst
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 subscribe --plan strix_cloud # hosted checkout page for the Cloud 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 <domain-id> # DNS record to add, then run it again
@ -109,6 +109,22 @@ strix cloud domains verify <domain-id> # DNS record to add, then run i
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`.
### Organization knowledge
Agents can manage the organization knowledge base without the dashboard (`knowledge:read` / `knowledge:write`):
```bash
strix cloud knowledge list --search authentication
strix cloud knowledge add --title "Authentication" --content "Staging uses SSO."
strix cloud knowledge update <document-id> --content "Staging uses SSO and TOTP."
strix cloud knowledge delete <document-id>
strix cloud knowledge policies add --key staging-only --content "Never test production."
strix cloud knowledge policies delete staging-only
strix cloud knowledge repos entries usestrix/strix
```
Knowledge policy writes require an admin token. Repository names are passed as normal `owner/name` values; the CLI handles URL encoding. The `costs` and `llm-settings` commands target on-prem installations and return `404` on app.strix.ai.
## 1. Register the target as an asset
Scans run against **registered assets**, not raw URLs. Register once, then reuse the returned UUID.

View file

@ -42,7 +42,11 @@ def run_cloud(argv: list[str]) -> int:
group, rest = argv[0], argv[1:]
if group in ("login", "logout", "whoami"):
session_argv = {"login": rest, "logout": ["logout"], "whoami": ["status"]}
session_argv = {
"login": rest,
"logout": ["logout"],
"whoami": ["status", *rest],
}
return run_login(session_argv[group])
if group == "credits":
group, rest = "billing", ["credits", *rest]

View file

@ -17,6 +17,7 @@ import time
import webbrowser
from pathlib import Path
from typing import Any, cast
from urllib.parse import quote
from rich.console import Console
@ -77,7 +78,8 @@ def run(group: str, verb_label: str, cmd: Cmd, argv: list[str]) -> int:
path = cmd.path
for name in _PLACEHOLDER.findall(cmd.path):
path = path.replace("{" + name + "}", str(getattr(args, _dest(name))))
value = quote(str(getattr(args, _dest(name))), safe="")
path = path.replace("{" + name + "}", value)
as_json = json_mode(flag=bool(getattr(args, "json", False)))
token = getattr(args, "token", None)
@ -351,7 +353,11 @@ def _topup(
challenge = http.parsed(response)
if getattr(args, "no_pay", False):
emit(console, challenge, as_json=as_json)
emit(
console,
{"error": "Payment required", "challenge": challenge},
as_json=as_json,
)
return http.EXIT_PAYMENT
npx = shutil.which("npx")

View file

@ -511,7 +511,7 @@ SPEC: dict[str, dict[str, Cmd]] = {
"product",
required=True,
flag="plan",
help="Product to buy, for example strix_pro, strix_cloud, or strix_top_up.",
help="Product to buy: strix_cloud, strix_startup, or strix_top_up.",
),
P("success_url", help="Page to open after the payment."),
),
@ -655,9 +655,11 @@ SPEC: dict[str, dict[str, Cmd]] = {
"/knowledge/policies",
"Add a knowledge policy.",
body=(
P("key", required=True, help="Policy key."),
P("content", help="Policy content."),
P("enabled", "bool", help="Turn the policy on or off."),
P("policy_key", required=True, flag="key", help="Policy key."),
P("policy_value", required=True, flag="content", help="Policy content."),
P("policy_type", help="Policy type. Defaults to constraint."),
P("is_active", "bool", flag="enabled", help="Turn the policy on or off."),
P("metadata", "json", help="JSON metadata for the policy."),
),
),
"policies delete": Cmd(

View file

@ -87,7 +87,7 @@ def run_login(argv: list[str]) -> int:
console.print(_LOGIN_USAGE)
return 0
if subcommand == "status":
return _status(console)
return _status(console, argv[1:])
if subcommand == "logout":
return _logout(console)
return _login(console, argv)
@ -468,14 +468,35 @@ def _print_success(console: Console, record: dict[str, Any]) -> None:
)
def _status(console: Console) -> int:
def _status(console: Console, argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog="strix cloud whoami")
parser.add_argument("--json", action="store_true", help="Print the session as JSON.")
try:
args = parser.parse_args(argv)
except SystemExit as exc:
return exc.code if isinstance(exc.code, int) else 2
record = read_record()
if record is None:
if args.json:
sys.stdout.write(json.dumps({"signed_in": False, "error": "Not signed in"}) + "\n")
return 1
console.print("[yellow]Not signed in.[/] Run [bold]strix cloud login[/] to sign in.")
return 1
email = record.get("email", "unknown")
organization = record.get("organization_name") or record.get("organization_id", "")
expires_at = record.get("expires_at", "")
if args.json:
payload = {
"signed_in": True,
"email": email,
"organization_id": record.get("organization_id"),
"organization_name": record.get("organization_name"),
"scopes": record.get("scopes", []),
"expires_at": expires_at or None,
}
sys.stdout.write(json.dumps(payload, indent=2, default=str) + "\n")
return 0
console.print(f"[green]Signed in[/] as [bold]{email}[/]")
if organization:
console.print(f" Workspace: {organization}")

View file

@ -102,6 +102,21 @@ def test_placeholder_substitution(monkeypatch: pytest.MonkeyPatch, capsys: Any)
assert json.loads(capsys.readouterr().out) == {"id": "abc"}
def test_placeholder_substitution_percent_encodes_path_segments(
monkeypatch: pytest.MonkeyPatch,
) -> None:
seen: dict[str, Any] = {}
def fake_request(_method: str, path: str, **_kwargs: Any) -> FakeResponse:
seen["path"] = path
return FakeResponse(payload={"entries": []})
monkeypatch.setattr(http, "request", fake_request)
code = cloud.run_cloud(["knowledge", "repos", "entries", "usestrix/.github", "--json"])
assert code == 0
assert seen["path"] == "/knowledge/repos/usestrix%2F.github/entries"
def test_query_and_body_collection(monkeypatch: pytest.MonkeyPatch) -> None:
seen: dict[str, Any] = {}
@ -282,6 +297,35 @@ def test_credits_alias_routes_to_billing(monkeypatch: pytest.MonkeyPatch) -> Non
assert seen["path"] == "/billing/credits"
def test_whoami_json_is_machine_readable_and_omits_the_token(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: Any
) -> None:
monkeypatch.delenv("STRIX_API_TOKEN", raising=False)
monkeypatch.setattr(platform_cli, "AUTH_PATH", tmp_path / "platform-auth.json")
platform_cli.save_record(
{
"api_token": "strix_pat_secret",
"email": "agent@example.test",
"organization_id": "org_1",
"organization_name": "Example",
"scopes": ["scans:read"],
"expires_at": "2026-09-01T00:00:00Z",
}
)
assert cloud.run_cloud(["whoami", "--json"]) == 0
payload = json.loads(capsys.readouterr().out)
assert payload == {
"signed_in": True,
"email": "agent@example.test",
"organization_id": "org_1",
"organization_name": "Example",
"scopes": ["scans:read"],
"expires_at": "2026-09-01T00:00:00Z",
}
assert "api_token" not in payload
def test_topup_no_pay_prints_challenge(monkeypatch: pytest.MonkeyPatch, capsys: Any) -> None:
challenge = {"payment_requirements": [{"amount": 500}]}
monkeypatch.setattr(
@ -289,7 +333,10 @@ def test_topup_no_pay_prints_challenge(monkeypatch: pytest.MonkeyPatch, capsys:
)
code = cloud.run_cloud(["billing", "topup", "--credits", "5", "--no-pay", "--json"])
assert code == http.EXIT_PAYMENT
assert json.loads(capsys.readouterr().out) == challenge
assert json.loads(capsys.readouterr().out) == {
"error": "Payment required",
"challenge": challenge,
}
def test_topup_success_without_payment(monkeypatch: pytest.MonkeyPatch, capsys: Any) -> None:
@ -393,14 +440,51 @@ def test_billing_subscribe_prints_checkout_url(
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"])
code = cloud.run_cloud(["billing", "subscribe", "--plan", "strix_cloud", "--json"])
assert code == 0
assert seen["method"] == "POST"
assert seen["path"] == "/billing/checkout"
assert seen["body"] == {"product": "strix_pro"}
assert seen["body"] == {"product": "strix_cloud"}
assert "https://pay.test/session" in capsys.readouterr().out
def test_knowledge_policy_flags_use_the_api_field_names(
monkeypatch: pytest.MonkeyPatch,
) -> None:
seen: dict[str, Any] = {}
def fake_request(_method: str, _path: str, **kwargs: Any) -> FakeResponse:
seen["body"] = kwargs.get("body")
return FakeResponse(payload={"success": True})
monkeypatch.setattr(http, "request", fake_request)
code = cloud.run_cloud(
[
"knowledge",
"policies",
"add",
"--key",
"no-production-data",
"--content",
"Never test production data.",
"--policy-type",
"constraint",
"--no-enabled",
"--metadata",
'{"owner":"security"}',
"--json",
]
)
assert code == 0
assert seen["body"] == {
"policy_key": "no-production-data",
"policy_value": "Never test production data.",
"policy_type": "constraint",
"is_active": False,
"metadata": {"owner": "security"},
}
def test_integration_install_url_does_not_open_browser(
monkeypatch: pytest.MonkeyPatch, capsys: Any
) -> None: