diff --git a/strix/interface/cloud/http.py b/strix/interface/cloud/http.py index 74df0b3f..1531f0b5 100644 --- a/strix/interface/cloud/http.py +++ b/strix/interface/cloud/http.py @@ -34,13 +34,30 @@ EXIT_AUTH = 4 EXIT_PAYMENT = 5 -class CloudError(Exception): - """A failed cloud command. Carries the process exit code.""" +TOPUP_COMMAND = "strix cloud billing topup --credits " +BALANCE_COMMAND = "strix cloud billing credits" - def __init__(self, message: str, *, exit_code: int = EXIT_ERROR, payload: Any = None) -> None: + +class CloudError(Exception): + """A failed cloud command. Carries the process exit code. + + `next_step` is a short recovery instruction that the runner prints on its + own line after the error, so a person or an agent can act without reading + the docs. + """ + + def __init__( + self, + message: str, + *, + exit_code: int = EXIT_ERROR, + payload: Any = None, + next_step: str | None = None, + ) -> None: super().__init__(message) self.exit_code = exit_code self.payload = payload + self.next_step = next_step class CloudTransportError(CloudError): @@ -349,13 +366,43 @@ def check(response: requests.Response) -> Any: error_code = error_code or str(nested.get("code") or "") detail = str(nested.get("message") or detail) message = detail or f"HTTP {response.status_code}" - if error_code == "scan_credit_limit_reached": - raise CloudError(message, exit_code=EXIT_PAYMENT, payload=data) + if error_code == "scan_credit_limit_reached" or response.status_code == 402: + raise payment_required_error(data, detail=detail) if response.status_code in (401, 403): raise CloudError(message, exit_code=EXIT_AUTH, payload=data) - if response.status_code == 402: - hint = detail or ( - "not enough credits. Run `strix cloud billing topup --credits N` to buy credits." - ) - raise CloudError(hint, exit_code=EXIT_PAYMENT, payload=data) raise CloudError(message, exit_code=EXIT_ERROR, payload=data) + + +def topup_url() -> str: + return f"{app_url()}/settings/billing" + + +def topup_next_step(url: str | None = None) -> str: + return ( + f"Buy credits with `{TOPUP_COMMAND}` or at {url or topup_url()}. " + f"Run `{BALANCE_COMMAND}` to see the balance. Then retry this command." + ) + + +def payment_required_error(data: Any, *, detail: str = "") -> CloudError: + """Build the error for an exhausted credit balance. + + The platform sends the recovery instruction in `hint` and repeats it inside + `detail`. The CLI shows the instruction once, on its own line, and adds its + own instruction when the platform sends none. + """ + server_hint = "" + server_url: str | None = None + if isinstance(data, dict): + raw = cast("dict[str, Any]", data) + server_hint = str(raw.get("hint") or "").strip() + raw_url = raw.get("topup_url") + if isinstance(raw_url, str) and raw_url.startswith("https://"): + server_url = raw_url + message = detail.strip() + if server_hint and message.endswith(server_hint): + message = message[: -len(server_hint)].strip() + if not message: + message = "Not enough credits to run this command." + next_step = server_hint or topup_next_step(server_url) + return CloudError(message, exit_code=EXIT_PAYMENT, payload=data, next_step=next_step) diff --git a/strix/interface/cloud/runner.py b/strix/interface/cloud/runner.py index 926ddaae..f35cc436 100644 --- a/strix/interface/cloud/runner.py +++ b/strix/interface/cloud/runner.py @@ -1035,10 +1035,14 @@ def _emit_error( payload = {"error": str(exc)} if exc.payload is not None: payload["detail"] = exc.payload + if exc.next_step: + payload["next_step"] = exc.next_step sys.stdout.write(json.dumps(payload, indent=2, default=str) + "\n") return target = Console(stderr=True) if to_stderr else console target.print(f"[red]Error:[/] {escape(sanitize_terminal_text(exc))}") + if exc.next_step: + target.print(f"[yellow]Next step:[/] {escape(sanitize_terminal_text(exc.next_step))}") def _emit_interrupted(console: Console, *, as_json: bool, to_stderr: bool) -> None: diff --git a/strix/interface/cloud/source_upload.py b/strix/interface/cloud/source_upload.py index bda3a0c8..780a8456 100644 --- a/strix/interface/cloud/source_upload.py +++ b/strix/interface/cloud/source_upload.py @@ -203,6 +203,17 @@ def prepare_source( """Select safe source files and build a bounded temporary ZIP archive.""" source = Path(value).expanduser().resolve() if not source.is_dir(): + if source.is_file() and ( + source.name.lower().endswith(_ARCHIVE_SUFFIXES) or _has_archive_magic(source) + ): + raise http.CloudError( + f"--source must be a directory, not an archive: {source}", + next_step=( + "Extract the archive and pass the directory to --source. Strix packs the " + "directory and excludes dependencies, build output, and secret-like files. " + "Add --dry-run --show-files to review the selection first." + ), + ) raise http.CloudError(f"--source must be a directory: {source}") manifest = select_source( source, @@ -224,14 +235,34 @@ def prepare_source( archive_bytes = archive_path.stat().st_size if archive_bytes > MAX_ARCHIVE_BYTES: archive_path.unlink(missing_ok=True) - raise http.CloudError( - "source archive is larger than the 50 MB upload limit; narrow --source or " - "add --exclude patterns." - ) + raise _archive_too_large_error(manifest, archive_bytes) digest = _sha256(archive_path) return SourceBundle(manifest, archive_path, archive_bytes, digest) +_LARGEST_FILES_SHOWN = 5 + + +def _format_mib(size: int) -> str: + return f"{size / (1024 * 1024):.1f} MiB" + + +def _archive_too_large_error(manifest: SourceManifest, archive_bytes: int) -> http.CloudError: + """Name the largest selected files so the user knows what to exclude.""" + largest = sorted(manifest.files, key=lambda item: item.size, reverse=True) + listed = ", ".join( + f"{item.archive_name} ({_format_mib(item.size)})" for item in largest[:_LARGEST_FILES_SHOWN] + ) + return http.CloudError( + f"the source archive is {_format_mib(archive_bytes)}, larger than the " + f"{_format_mib(MAX_ARCHIVE_BYTES)} upload limit. Largest files: {listed}.", + next_step=( + "Add --exclude patterns for large files or directories, or point --source at a " + "smaller directory. Run with --dry-run --show-files to review the selection." + ), + ) + + def select_source( source: Path, *, diff --git a/strix/interface/cloud/spec.py b/strix/interface/cloud/spec.py index 5b64a120..c9ec5d2b 100644 --- a/strix/interface/cloud/spec.py +++ b/strix/interface/cloud/spec.py @@ -1066,7 +1066,8 @@ SPEC: dict[str, dict[str, Cmd]] = { "request": Cmd( "POST", "/uploads/request", - "Request an upload URL.", + "Request an upload URL. To scan local source, prefer `strix cloud scans start " + "--source DIR`, which packs, uploads, and starts the scan in one step.", body=( P("file_name", required=True, help="File name."), P("file_size", "int", required=True, help="File size in bytes."), diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py index 363f4564..41962b9d 100644 --- a/tests/test_cloud_cli.py +++ b/tests/test_cloud_cli.py @@ -534,6 +534,69 @@ def test_insufficient_credits_exits_with_payment_code(monkeypatch: pytest.Monkey assert cloud.run_cloud(["scans", "start", "--domain-ids", "d1"]) == http.EXIT_PAYMENT +def test_insufficient_credits_always_prints_topup_instruction( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + status_code=402, + payload={"detail": "Out of credits.", "code": "scan_credit_limit_reached"}, + ), + ) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + argv = ["scans", "start", "--domain-ids", "d1", "--app-url", "https://app.strix.ai"] + assert cloud.run_cloud(argv) == http.EXIT_PAYMENT + output = " ".join(capsys.readouterr().out.split()) + assert "Error: Out of credits." in output + assert "Next step:" in output + assert "strix cloud billing topup --credits " in output + assert "https://app.strix.ai/settings/billing" in output + assert "strix cloud billing credits" in output + + +def test_insufficient_credits_shows_platform_hint_once( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + hint = "Buy credits at https://app.strix.ai/settings/billing. Then retry this request." + payload = { + "detail": f"Out of credits. {hint}", + "code": "scan_credit_limit_reached", + "hint": hint, + "topup_url": "https://app.strix.ai/settings/billing", + } + monkeypatch.setattr( + http, "request", lambda *_a, **_k: FakeResponse(status_code=402, payload=payload) + ) + assert cloud.run_cloud(["scans", "start", "--domain-ids", "d1", "--json"]) == http.EXIT_PAYMENT + result = json.loads(capsys.readouterr().out) + assert result["error"] == "Out of credits." + assert result["next_step"] == hint + assert result["topup_url"] == "https://app.strix.ai/settings/billing" + + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + assert cloud.run_cloud(["scans", "start", "--domain-ids", "d1"]) == http.EXIT_PAYMENT + output = " ".join(capsys.readouterr().out.split()) + assert output.count(hint) == 1 + assert "Error: Out of credits." in output + assert f"Next step: {hint}" in output + + +def test_payment_required_without_body_names_the_topup_command( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr( + http, "request", lambda *_a, **_k: FakeResponse(status_code=402, payload={}) + ) + argv = ["scans", "start", "--domain-ids", "d1", "--json", "--app-url", "https://app.strix.ai"] + assert cloud.run_cloud(argv) == http.EXIT_PAYMENT + result = json.loads(capsys.readouterr().out) + assert result["error"] == "Not enough credits to run this command." + assert "strix cloud billing topup --credits " in result["next_step"] + assert "https://app.strix.ai/settings/billing" in result["next_step"] + + def test_data_rejects_non_object() -> None: assert cloud.run_cloud(["scans", "start", "--data", "[1,2]"]) == http.EXIT_USAGE assert cloud.run_cloud(["scans", "start", "--data", "not json"]) == http.EXIT_USAGE diff --git a/tests/test_cloud_source_upload.py b/tests/test_cloud_source_upload.py index 24004cd8..dcd40baa 100644 --- a/tests/test_cloud_source_upload.py +++ b/tests/test_cloud_source_upload.py @@ -655,3 +655,44 @@ def test_incomplete_upload_credentials_delete_the_reserved_upload( monkeypatch.setattr(http, "request", fake_request) assert cloud.run_cloud(["scans", "start", "--source", str(tmp_path), "--yes", "--json"]) == 1 assert ("DELETE", "/uploads/upload-incomplete") in paths + + +def test_archive_source_is_rejected_with_directory_guidance(tmp_path: Path) -> None: + archive = tmp_path / "backend.zip" + with zipfile.ZipFile(archive, "w") as bundle: + bundle.writestr("app.py", "print('safe')\n") + + with pytest.raises(http.CloudError, match="not an archive") as raised: + source_upload.prepare_source( + str(archive), + include_hidden=False, + include_sensitive=False, + include_archives=False, + exclude=[], + ) + assert raised.value.next_step is not None + assert "--source" in raised.value.next_step + assert "--dry-run --show-files" in raised.value.next_step + + +def test_oversize_archive_names_largest_files_and_exclude_guidance( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8") + (tmp_path / "big.bin").write_bytes(os.urandom(4096)) + monkeypatch.setattr(source_upload, "MAX_ARCHIVE_BYTES", 1024) + + with pytest.raises(http.CloudError, match=r"larger than the 0\.0 MiB upload limit") as raised: + source_upload.prepare_source( + str(tmp_path), + include_hidden=False, + include_sensitive=False, + include_archives=False, + exclude=[], + ) + message = str(raised.value) + assert message.index("big.bin") < message.index("app.py") + assert raised.value.next_step is not None + assert "--exclude" in raised.value.next_step + assert "--dry-run --show-files" in raised.value.next_step + assert not list(tmp_path.glob("strix-source-*.zip"))