mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/usestrix/strix.git
synced 2026-09-21 00:23:52 +08:00
Compare commits
8 Commits
docs/trim-
...
feat/cli-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d14cf96bd | ||
|
|
5d015df6b1 | ||
|
|
1edafd3e80 | ||
|
|
f1e24fe3f2 | ||
|
|
e644f4a02c | ||
|
|
1ebe1007e8 | ||
|
|
53d2e5cfeb | ||
|
|
7708f717d5 |
72
README.md
72
README.md
@@ -116,7 +116,9 @@ Strix is agent-ready. Give Claude Code, Cursor, Codex, or any [SKILL.md-compatib
|
||||
npx skills add usestrix/strix
|
||||
```
|
||||
|
||||
This installs nine skills: **penetration-testing-with-strix** (run headless scans and read results), **managed-pentesting-with-strix** (drive the managed [app.strix.ai](https://app.strix.ai) platform via REST — no local Docker or LLM key), **fix-security-vulnerabilities-with-strix** (remediate + re-scan to verify), **ci-security-scanning-with-strix** (PR scanning in CI), plus target-specific workflows: **application-security-testing**, **web-app-penetration-testing**, **api-security-testing**, **owasp-top-10-testing**, and **find-security-vulnerabilities-in-code**. Agents can run Strix two ways with the same engine — the open-source CLI locally, or the managed cloud when there's no local infra — and read [`AGENTS.md`](AGENTS.md) for a quick reference, [docs.strix.ai/llms.txt](https://docs.strix.ai/llms.txt) for the CLI docs, and [docs.app.strix.ai](https://docs.app.strix.ai) for the API.
|
||||
This installs nine skills for running pentests, fixing findings, and CI scanning, against code, web apps, APIs, and the OWASP Top 10. Agents can use the local CLI or the managed cloud with the same engine.
|
||||
|
||||
See [`AGENTS.md`](AGENTS.md) for the quick reference, [docs.strix.ai/llms.txt](https://docs.strix.ai/llms.txt) for the CLI, and [docs.app.strix.ai](https://docs.app.strix.ai) for the API.
|
||||
|
||||
---
|
||||
|
||||
@@ -172,18 +174,9 @@ strix view my-run-name
|
||||
strix view --host 0.0.0.0 --port 8080 --no-open
|
||||
```
|
||||
|
||||
`strix view` starts a lightweight local server (bound to `127.0.0.1` on a random port) and opens your browser to a private, tokened link. Nothing leaves your machine: the dashboard reads the run's files straight off disk, with no cloud account or upload required. The UI ships prebuilt with Strix, so there is no extra install and no JS build step.
|
||||
The dashboard shows the findings, a live map of the agent team, and past runs. Nothing leaves your machine, and the UI ships prebuilt. `strix view` binds to `127.0.0.1` and prints a tokened link that grants access to the run, so share it carefully.
|
||||
|
||||
Use `--host 0.0.0.0` to make the viewer reachable from other machines. Replace `0.0.0.0` in the printed URL with the server's reachable IP or hostname. The token in that URL grants access to the selected run's scan data, history, and steering, so only share it with trusted users and restrict the port with your firewall. Requests without the token-derived session cannot read run data.
|
||||
|
||||
### What's in the dashboard
|
||||
|
||||
- **Overview**: run status, target, and a severity breakdown of everything found so far.
|
||||
- **Vulnerabilities**: each validated finding with its severity, details, and reproduction steps.
|
||||
- **Agent graph**: a live map of the multi-agent team, showing which agent is doing what.
|
||||
- **Steering**: send instructions to a live scan from the browser to redirect the agents mid-run.
|
||||
- **History**: browse past runs on this machine and jump between them.
|
||||
- **Reports**: generate a shareable report and email it to yourself or your team.
|
||||
See the [viewer documentation](https://docs.strix.ai/usage/viewer) for the options and for reaching the viewer from another machine.
|
||||
|
||||
---
|
||||
|
||||
@@ -209,18 +202,9 @@ having to discover them by crawling. Pair the spec with the live base URL so the
|
||||
agent knows where to send traffic:
|
||||
|
||||
```bash
|
||||
# OpenAPI / Swagger file (.json / .yaml)
|
||||
# OpenAPI / Swagger file, Postman export, or a live collection by id
|
||||
strix --target ./openapi.yaml --target https://api.your-app.com
|
||||
|
||||
# Postman collection export
|
||||
strix --target ./collection.postman_collection.json --target https://api.your-app.com
|
||||
|
||||
# Postman collection pulled live by id (no manual export)
|
||||
export POSTMAN_API_KEY="PMAK-..."
|
||||
strix --target postman://<collection-uuid>
|
||||
|
||||
# ...with a Postman environment to resolve {{baseUrl}} / token variables
|
||||
strix --target "postman://<collection-uuid>?env=<environment-uuid>"
|
||||
strix --target postman://<collection-uuid> --target https://api.your-app.com
|
||||
```
|
||||
|
||||
|
||||
@@ -235,20 +219,10 @@ strix -t https://github.com/org/app -t https://your-app.com
|
||||
|
||||
# Targets from a file, one target per non-empty, non-comment line
|
||||
strix --target-list ./targets.txt
|
||||
|
||||
# White-box source-aware scan (local repository)
|
||||
strix --target ./app-directory --scan-mode standard
|
||||
|
||||
# Focused testing with custom instructions
|
||||
strix --target api.your-app.com --instruction "Focus on business logic flaws and IDOR vulnerabilities"
|
||||
|
||||
# Provide detailed instructions through file (e.g., rules of engagement, scope, exclusions)
|
||||
strix --target api.your-app.com --instruction-file ./instruction.md
|
||||
|
||||
# Force PR diff-scope against a specific base branch
|
||||
strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main
|
||||
```
|
||||
|
||||
See the [CLI reference](https://docs.strix.ai/usage/cli) for every option, including scan modes, diff scope, instruction files, and budgets.
|
||||
|
||||
### Headless Mode
|
||||
|
||||
Run Strix programmatically without interactive UI using the `-n/--non-interactive` flag - perfect for servers and automated jobs. The CLI prints real-time vulnerability findings and the final report before exiting. Exits with non-zero code when vulnerabilities are found.
|
||||
@@ -287,9 +261,9 @@ jobs:
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> In CI pull request runs, Strix automatically scopes quick reviews to changed files.
|
||||
> If diff-scope cannot resolve, ensure checkout uses full history (`fetch-depth: 0`) or pass
|
||||
> `--diff-base` explicitly.
|
||||
> In CI pull request runs, Strix automatically scopes quick reviews to changed files, which is why the
|
||||
> checkout above fetches full history. See the
|
||||
> [CI/CD documentation](https://docs.strix.ai/integrations/github-actions) for the details.
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -300,24 +274,20 @@ export LLM_API_KEY="your-api-key"
|
||||
# Optional
|
||||
export LLM_API_BASE="your-api-base-url" # if using a local model, e.g. Ollama, LMStudio
|
||||
export PERPLEXITY_API_KEY="your-api-key" # for search capabilities
|
||||
export STRIX_REASONING_EFFORT="high" # control thinking effort (default: high, quick scan: medium)
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Strix automatically saves your configuration to `~/.strix/cli-config.json`, so you don't have to re-enter it on every run.
|
||||
> See the [configuration reference](https://docs.strix.ai/advanced/configuration) for every environment variable.
|
||||
|
||||
#### Sign in with a ChatGPT subscription
|
||||
|
||||
Instead of a metered API key, you can run Strix on your ChatGPT Plus/Pro subscription:
|
||||
|
||||
```bash
|
||||
strix auth login chatgpt # sign in with your ChatGPT account
|
||||
|
||||
strix auth login chatgpt # sign in with your ChatGPT account
|
||||
export STRIX_LLM="chatgpt/gpt-5.4" # chatgpt/<model> runs on the subscription
|
||||
strix --target ./app-directory
|
||||
|
||||
strix auth status # show the active sign-in
|
||||
strix auth logout # forget the sign-in
|
||||
strix auth status # show the active sign-in, or logout to forget it
|
||||
```
|
||||
|
||||
#### Use the managed platform: `strix cloud`
|
||||
@@ -333,20 +303,14 @@ strix cloud vulns list --severity critical
|
||||
|
||||
Every [REST API](https://docs.app.strix.ai) operation has a matching `strix cloud <resource> <verb>` command. Run `strix cloud` to list the resources, and add `help` to a resource to list its verbs. Output is JSON when stdout is not a terminal or when you pass `--json`. Binary downloads are the exception: redirect the raw bytes, or combine `--output FILE --json` for download metadata.
|
||||
|
||||
See the [platform documentation](https://docs.app.strix.ai) for scopes, workspaces, billing, and source-upload options.
|
||||
See the [cloud CLI documentation](https://docs.strix.ai/cloud/cli) for scopes, workspaces, billing, and source-upload options.
|
||||
|
||||
#### Connect your own MCP servers
|
||||
|
||||
Strix can connect to Model Context Protocol (MCP) servers you list and expose their tools to the agent during a run. Create `~/.strix/mcp-servers.json` with a JSON list of servers. Each entry is either a local `stdio` server that Strix launches as a subprocess, or a remote `http` server:
|
||||
Strix can connect to Model Context Protocol (MCP) servers you list and expose their tools to the agent during a run. Create `~/.strix/mcp-servers.json` with a JSON list of local `stdio` servers or remote `http` servers:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "local_fs",
|
||||
"transport": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"]
|
||||
},
|
||||
{
|
||||
"name": "github",
|
||||
"transport": "http",
|
||||
@@ -357,7 +321,7 @@ Strix can connect to Model Context Protocol (MCP) servers you list and expose th
|
||||
]
|
||||
```
|
||||
|
||||
Each server's tools are namespaced by `name` (for example `local_fs_read_file`). Omit `allowed_tools` to expose every tool the server offers, or set it to a list to restrict which tools the agent can call. The file is optional, and a server that fails to connect is skipped without failing the run. You can point Strix at a different file with `STRIX_MCP_CONFIG`.
|
||||
Each server's tools are namespaced by `name`, for example `github_list_issues`. See the [MCP documentation](https://docs.strix.ai/integrations/mcp) for the full schema, tool filtering, and `stdio` servers.
|
||||
|
||||
**Recommended models for best results:**
|
||||
|
||||
|
||||
105
docs/cloud/cli.mdx
Normal file
105
docs/cloud/cli.mdx
Normal file
@@ -0,0 +1,105 @@
|
||||
---
|
||||
title: "Cloud CLI"
|
||||
description: "Drive app.strix.ai from the terminal with strix cloud"
|
||||
---
|
||||
|
||||
The `strix cloud` commands drive the managed platform ([app.strix.ai](https://app.strix.ai)) from the terminal. You do not need Docker or an LLM key.
|
||||
|
||||
## Sign In
|
||||
|
||||
Sign in once with the browser device flow. The sign-in creates your account and workspace on first use, and it stores a personal API token in `~/.strix/platform-auth.json`.
|
||||
|
||||
```bash
|
||||
strix cloud login # browser approval, then workspace and scope profile
|
||||
strix cloud login --workspace "My Team" # select a workspace by name or ID
|
||||
strix cloud whoami # local account and workspace status
|
||||
strix cloud session # verify the remote session and consent ceiling
|
||||
strix cloud logout # revoke remotely, then remove the local token
|
||||
```
|
||||
|
||||
On your first sign-in in a terminal, the CLI asks for a workspace name and offers to open the GitHub App installation page. Press Enter to keep the generated name. After each sign-in, the CLI lists the next steps for the workspace, for example the rename command, the GitHub installation link, or the first scan. Without a terminal, the CLI shows no prompts and only prints the steps.
|
||||
|
||||
A browser sign-in creates one reusable credential for each CLI installation. A second sign-in on the same installation replaces the secret instead of adding another key. `strix cloud logout` revokes the server session before it deletes the local token. Use `--local-only` when you cannot reach the server.
|
||||
|
||||
## Scopes
|
||||
|
||||
The default **Recommended** preset covers normal scan work, local source uploads, workspace switching, and user-approved credit top-ups. It excludes credential creation, so request `tokens:write` when you need it.
|
||||
|
||||
```bash
|
||||
strix cloud login --scopes scans:read scans:write uploads:write billing:read
|
||||
strix cloud login --scope-profile minimal # also accepts recommended or full
|
||||
strix cloud session scopes # granted scopes and the login ceiling
|
||||
strix cloud session scopes set minimal # narrow without another browser sign-in
|
||||
```
|
||||
|
||||
A workspace switch keeps the credential and its expiry, preserves the server-side scope preference, and caps access by the target role. A switch can never exceed the login consent ceiling. Each process pins the workspace it started with, so a concurrent switch fails safely instead of sending a stale command to another organization.
|
||||
|
||||
## Commands
|
||||
|
||||
Every operation of the [REST API](https://docs.app.strix.ai) has a matching command in the form `strix cloud <resource> <verb>`.
|
||||
|
||||
```bash
|
||||
strix cloud # list all resources
|
||||
strix cloud scans # run the safe default (scans list)
|
||||
strix cloud scans help # list the verbs of a resource
|
||||
strix cloud domains add --domain example.com --asset-type web_app
|
||||
strix cloud scans start --engagement-type live_test --domain-ids <uuid> --wait
|
||||
strix cloud vulns list --severity critical
|
||||
strix cloud credits # credit balance
|
||||
```
|
||||
|
||||
Write commands take request fields as flags. Every write command also accepts one JSON object with `--data`:
|
||||
|
||||
```bash
|
||||
strix cloud scans start --data '{"engagement_type":"code_review"}' # literal JSON
|
||||
strix cloud scans start --data @request.json # read a file
|
||||
cat request.json | strix cloud scans start --data - # read standard input
|
||||
```
|
||||
|
||||
`--token` and `STRIX_API_TOKEN` are stateless overrides for a single command, and they never replace the stored sign-in. Pair a CLI-session override with `--workspace-id` or `STRIX_WORKSPACE_ID`.
|
||||
|
||||
## Workspaces And Account Setup
|
||||
|
||||
```bash
|
||||
strix cloud workspaces list # numbered list; workspace is also accepted
|
||||
strix cloud workspaces create --name "My Team" # needs admin and organizations:write
|
||||
strix cloud workspaces use 2 # switch by list number, exact name, or ID
|
||||
strix cloud billing topup --credits 20 --yes # approve an agent payment after HTTP 402
|
||||
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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Output And Exit Codes
|
||||
|
||||
The commands work for people and for agents. Terminal output favors names, branches, lifecycle states, and numbered selectors. Redirected output, and `--json`, preserve the complete machine-readable record.
|
||||
|
||||
- Human lists keep the selectors that follow-up commands need, and they omit internal organization and user IDs. A selector that is too long for the compact table is repeated losslessly in a copyable block.
|
||||
- Paginated lists print the next `--page` or `--offset`. Detail views keep useful prose within a safe terminal bound, so use `--json` for the complete record.
|
||||
- Token lists separate API keys from named CLI device sessions.
|
||||
- Binary downloads are the exception to JSON output. Redirect the raw bytes on purpose, or use `--output FILE --json` to write the file and receive structured download metadata.
|
||||
- There are no prompts when stdin is not a terminal.
|
||||
|
||||
Exit codes: `0` success, `1` error, `2` invalid usage, `4` authentication or plan limit, `5` payment required.
|
||||
|
||||
## Credits And Plan Limits
|
||||
|
||||
Non-Enterprise scans consume the deterministic estimate shown for their scope. A source-only code review at the default `ultra` tier currently starts at 60 credits. Enterprise scans are plan-included and do not consume the credit wallet.
|
||||
|
||||
Report downloads need Enterprise, schedules need Pro, and billing writes need an admin token. A plan block exits `4`. An insufficient credit wallet exits `5` without the creation of a scan and without a charge.
|
||||
|
||||
## Local Source Scans
|
||||
|
||||
See [Scan Local Source](/cloud/overview#scan-local-source) for the upload approval flow, the exclusion rules, and the size limits.
|
||||
|
||||
## Tab Completion
|
||||
|
||||
Enable native tab completion once for each shell session:
|
||||
|
||||
```bash
|
||||
source <(strix completions zsh) # use bash instead of zsh when appropriate
|
||||
strix completions fish | source
|
||||
```
|
||||
@@ -25,7 +25,8 @@
|
||||
"pages": [
|
||||
"usage/cli",
|
||||
"usage/scan-modes",
|
||||
"usage/instructions"
|
||||
"usage/instructions",
|
||||
"usage/viewer"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -77,7 +78,8 @@
|
||||
{
|
||||
"group": "Strix Cloud",
|
||||
"pages": [
|
||||
"cloud/overview"
|
||||
"cloud/overview",
|
||||
"cloud/cli"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
49
docs/usage/viewer.mdx
Normal file
49
docs/usage/viewer.mdx
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: "Local Web Viewer"
|
||||
description: "Browse a run in a local dashboard with strix view"
|
||||
---
|
||||
|
||||
Every scan writes its results to disk as it runs. `strix view` serves those files in a local dashboard, for a live run or a finished one.
|
||||
|
||||
```bash
|
||||
strix view # the most recent run
|
||||
strix view my-run-name # a specific run under ./strix_runs
|
||||
strix view --host 0.0.0.0 --port 8080 --no-open
|
||||
```
|
||||
|
||||
The UI ships prebuilt with Strix, so there is no extra install and no JavaScript build step. The dashboard reads the run files straight off disk. Nothing leaves your machine, and you do not need a cloud account.
|
||||
|
||||
## Options
|
||||
|
||||
<ParamField path="run" type="string">
|
||||
Run name under `./strix_runs`. Defaults to the most recent run.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="--host" type="string" default="127.0.0.1">
|
||||
Host to bind to. Use `0.0.0.0` to reach the viewer from other machines.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="--port" type="number" default="0">
|
||||
Port to serve on. The default selects an available ephemeral port.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="--no-open" type="boolean">
|
||||
Do not open the browser automatically.
|
||||
</ParamField>
|
||||
|
||||
## What Is In The Dashboard
|
||||
|
||||
- **Overview** — run status, target, and a severity breakdown of everything found so far.
|
||||
- **Vulnerabilities** — each validated finding with its severity, details, and reproduction steps.
|
||||
- **Agent graph** — a live map of the multi-agent team, and what each agent is doing.
|
||||
- **Steering** — send instructions to a live scan to redirect the agents during the run. Steering works only in the dashboard the running scan opens. A standalone `strix view` has no live scan to steer.
|
||||
- **History** — browse past runs on this machine and move between them. Verify your email address in the dashboard to unlock the other runs.
|
||||
- **Reports** — generate a shareable report and send it by email. Verify your email address first.
|
||||
|
||||
## Sharing The Link
|
||||
|
||||
<Warning>
|
||||
The token in the printed URL grants access to the run data, and to the steering of a live scan. Share it only with trusted users.
|
||||
</Warning>
|
||||
|
||||
To reach the viewer from another machine, start it with `--host 0.0.0.0` and replace `0.0.0.0` in the printed URL with a reachable IP address or hostname. Restrict the port with your firewall. A request without the token-derived session cannot read run data.
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "strix-agent"
|
||||
version = "1.6.0"
|
||||
version = "1.6.1"
|
||||
description = "Open-source AI Hackers for your apps"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -24,6 +24,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
Status = Literal["running", "waiting", "completed", "stopped", "crashed", "failed", "budget_paused"]
|
||||
|
||||
TERMINAL_STATUSES: frozenset[str] = frozenset({"completed", "stopped", "crashed", "failed"})
|
||||
|
||||
# Why an agent parked. The user can message any agent, so this - not the agent's
|
||||
# position in the tree - decides whether waiting is bounded: only an agent waiting
|
||||
# on other agents is re-checked on a timer.
|
||||
@@ -36,6 +38,10 @@ class AgentRuntime:
|
||||
task: asyncio.Task[Any] | None = None
|
||||
stream: Any | None = None
|
||||
interrupt_on_message: bool = False
|
||||
# Whether the agent's loop parks after a terminal state and can be woken by a
|
||||
# later message. A non-interactive loop returns instead, so once such an
|
||||
# agent is terminal nothing will ever read its mailbox again.
|
||||
resumable: bool = True
|
||||
wake: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
mailbox: list[dict[str, Any]] = field(default_factory=list)
|
||||
user_wake_required: bool = False
|
||||
@@ -175,6 +181,7 @@ class AgentCoordinator:
|
||||
session: Session | None = None,
|
||||
task: asyncio.Task[Any] | None = None,
|
||||
interrupt_on_message: bool | None = None,
|
||||
resumable: bool | None = None,
|
||||
) -> None:
|
||||
async with self._lock:
|
||||
runtime = self.runtimes.setdefault(agent_id, AgentRuntime())
|
||||
@@ -184,6 +191,8 @@ class AgentCoordinator:
|
||||
runtime.task = task
|
||||
if interrupt_on_message is not None:
|
||||
runtime.interrupt_on_message = interrupt_on_message
|
||||
if resumable is not None:
|
||||
runtime.resumable = resumable
|
||||
|
||||
async def mark_running(self, agent_id: str) -> None:
|
||||
async with self._lock:
|
||||
@@ -275,10 +284,29 @@ class AgentCoordinator:
|
||||
self._parent_notified.add(agent_id)
|
||||
return True
|
||||
|
||||
def _unreachable_locked(self, agent_id: str) -> bool:
|
||||
"""True when the agent is terminal and no loop will ever read its mailbox."""
|
||||
if self.statuses.get(agent_id) not in TERMINAL_STATUSES:
|
||||
return False
|
||||
runtime = self.runtimes.get(agent_id)
|
||||
return runtime is not None and not runtime.resumable
|
||||
|
||||
async def reachability(self, agent_id: str) -> tuple[bool, Status | None]:
|
||||
"""Whether a message to ``agent_id`` can still be acted on, plus its status."""
|
||||
async with self._lock:
|
||||
status = self.statuses.get(agent_id)
|
||||
if status is None:
|
||||
return False, None
|
||||
return not self._unreachable_locked(agent_id), status
|
||||
|
||||
async def send(
|
||||
self, target_agent_id: str, message: dict[str, Any], *, interrupt: bool = True
|
||||
) -> bool:
|
||||
"""Queue a user/peer message in the target's mailbox and wake it."""
|
||||
"""Queue a user/peer message in the target's mailbox and wake it.
|
||||
|
||||
Returns False when nothing will ever read the message: the target is
|
||||
unknown, or it is terminal and its loop does not park for wake-ups.
|
||||
"""
|
||||
from_user = message.get("from") == "user"
|
||||
if from_user and self._budget_paused:
|
||||
await self.resume_from_budget_pause(exclude=target_agent_id)
|
||||
@@ -286,6 +314,13 @@ class AgentCoordinator:
|
||||
if target_agent_id not in self.statuses:
|
||||
logger.debug("agent.send dropped unknown target=%s", target_agent_id)
|
||||
return False
|
||||
if self._unreachable_locked(target_agent_id):
|
||||
logger.info(
|
||||
"agent.send dropped: target=%s is %s and cannot be woken",
|
||||
target_agent_id,
|
||||
self.statuses[target_agent_id],
|
||||
)
|
||||
return False
|
||||
runtime = self.runtimes.setdefault(target_agent_id, AgentRuntime())
|
||||
runtime.mailbox.append(dict(message))
|
||||
self.pending_counts[target_agent_id] = self.pending_counts.get(target_agent_id, 0) + 1
|
||||
|
||||
@@ -202,6 +202,7 @@ async def run_agent_loop(
|
||||
agent_id,
|
||||
session=session,
|
||||
interrupt_on_message=interactive,
|
||||
resumable=interactive,
|
||||
)
|
||||
result: RunResultBase | None = None
|
||||
|
||||
@@ -1006,7 +1007,7 @@ async def _start_child_runner(
|
||||
) -> None:
|
||||
session = open_agent_session(child_id, agents_db_path)
|
||||
sessions_to_close.append(session)
|
||||
await coordinator.attach_runtime(child_id, session=session)
|
||||
await coordinator.attach_runtime(child_id, session=session, resumable=interactive)
|
||||
|
||||
child_ctx: dict[str, Any] = dict(parent_ctx)
|
||||
child_ctx["agent_id"] = child_id
|
||||
|
||||
@@ -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 <count>"
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -39,6 +39,16 @@ _MAX_POLL_INTERVAL_S = 60
|
||||
_MAX_EXPIRES_IN_S = 30 * 60
|
||||
|
||||
_ROLE_RANK = {"viewer": 0, "analyst": 1, "admin": 2}
|
||||
_MAX_WORKSPACE_NAME_LENGTH = 100
|
||||
|
||||
# Sign-in guidance is shown once and never written to the credential file.
|
||||
_LOGIN_GUIDANCE_KEYS = (
|
||||
"is_new_user",
|
||||
"onboarding",
|
||||
"next_steps",
|
||||
"next_steps_hint",
|
||||
"dashboard_url",
|
||||
)
|
||||
|
||||
|
||||
class PlatformAuthError(Exception):
|
||||
@@ -172,6 +182,7 @@ def _login(console: Console, argv: list[str]) -> int:
|
||||
console.print("\n[yellow]Sign-in cancelled.[/]")
|
||||
return 130
|
||||
|
||||
record, guidance = _split_login_guidance(record)
|
||||
try:
|
||||
save_record(record)
|
||||
except OSError as exc:
|
||||
@@ -184,10 +195,18 @@ def _login(console: Console, argv: list[str]) -> int:
|
||||
)
|
||||
return 1
|
||||
_revoke_replaced_legacy_session(previous_record, record)
|
||||
_print_success(console, record)
|
||||
_print_success(console, record, guidance)
|
||||
_offer_github_install(console, guidance, open_browser=not args.no_browser)
|
||||
return 0
|
||||
|
||||
|
||||
def _split_login_guidance(record: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""Separate the one-time setup guidance from the credential that is stored."""
|
||||
guidance = {key: record[key] for key in _LOGIN_GUIDANCE_KEYS if key in record}
|
||||
stored = {key: value for key, value in record.items() if key not in guidance}
|
||||
return stored, guidance
|
||||
|
||||
|
||||
def _run_device_flow( # noqa: PLR0912, PLR0915
|
||||
console: Console,
|
||||
*,
|
||||
@@ -395,6 +414,9 @@ def _complete_selection(
|
||||
|
||||
chosen_org = _choose_workspace(console, organizations, workspace)
|
||||
role = str(chosen_org.get("role") or "admin")
|
||||
workspace_name: str | None = None
|
||||
if sys.stdin.isatty() and selection.get("is_new_user") and chosen_org.get("has_default_name"):
|
||||
workspace_name = _choose_workspace_name(console, str(chosen_org.get("name") or ""))
|
||||
chosen_scopes = scopes
|
||||
chosen_profile = scope_profile
|
||||
if chosen_scopes is None and chosen_profile is None and sys.stdin.isatty():
|
||||
@@ -404,6 +426,8 @@ def _complete_selection(
|
||||
"selection_token": selection_token,
|
||||
"organization_id": chosen_org.get("id"),
|
||||
}
|
||||
if workspace_name is not None:
|
||||
body["workspace_name"] = workspace_name
|
||||
if chosen_scopes is not None:
|
||||
body["scopes"] = chosen_scopes
|
||||
body["scope_profile"] = "custom"
|
||||
@@ -476,6 +500,29 @@ def _choose_workspace(
|
||||
console.print("[yellow]Enter a number from the list.[/]")
|
||||
|
||||
|
||||
def _choose_workspace_name(console: Console, current: str) -> str | None:
|
||||
"""Prompt a first-time user for a workspace name. None keeps the generated name."""
|
||||
console.print()
|
||||
console.print(
|
||||
"[bold]Name your workspace.[/] "
|
||||
"[dim]Teammates see this name. Press Enter to keep the default. "
|
||||
"You can change it later with `strix cloud org update --name`.[/]"
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
answer = console.input(f"Workspace name ({_terminal_markup(current)}): ").strip()
|
||||
except EOFError:
|
||||
return None
|
||||
if not answer or answer == current:
|
||||
return None
|
||||
if len(answer) > _MAX_WORKSPACE_NAME_LENGTH:
|
||||
console.print(
|
||||
f"[yellow]Use a name with {_MAX_WORKSPACE_NAME_LENGTH} characters or less.[/]"
|
||||
)
|
||||
continue
|
||||
return answer
|
||||
|
||||
|
||||
def _choose_scopes(
|
||||
console: Console, catalog: list[dict[str, Any]], role: str
|
||||
) -> tuple[str, list[str] | None]:
|
||||
@@ -632,19 +679,30 @@ def _revoke_replaced_legacy_session(
|
||||
)
|
||||
|
||||
|
||||
def _print_success(console: Console, record: dict[str, Any]) -> None:
|
||||
def _print_success(
|
||||
console: Console, record: dict[str, Any], guidance: dict[str, Any] | None = None
|
||||
) -> None:
|
||||
guidance = guidance or {}
|
||||
onboarding = _onboarding_state(guidance)
|
||||
email = record.get("email", "")
|
||||
organization = record.get("organization_name") or record.get("organization_id", "")
|
||||
console.print()
|
||||
console.print("[green]✓ Signed in to the Strix platform.[/]")
|
||||
if guidance.get("is_new_user"):
|
||||
console.print("[green]✓ Welcome to Strix. Your account and workspace are ready.[/]")
|
||||
else:
|
||||
console.print("[green]✓ Signed in to the Strix platform.[/]")
|
||||
if email:
|
||||
console.print(f" Account: [bold]{_terminal_markup(email)}[/]")
|
||||
if organization:
|
||||
console.print(f" Workspace: [bold]{_terminal_markup(organization)}[/]")
|
||||
default_note = (
|
||||
" [dim](default name)[/]" if onboarding.get("workspace_named") is False else ""
|
||||
)
|
||||
console.print(f" Workspace: [bold]{_terminal_markup(organization)}[/]{default_note}")
|
||||
scopes = record.get("scopes")
|
||||
if isinstance(scopes, list) and scopes:
|
||||
console.print(f" Access: [dim]{_terminal_markup(_scope_summary(record))}[/]")
|
||||
console.print(f" Token: stored in [dim]{_terminal_markup(AUTH_PATH)}[/]")
|
||||
_print_next_steps(console, guidance)
|
||||
console.print()
|
||||
console.print(
|
||||
"[dim]The managed platform is ready. Run `strix cloud` to list the commands. "
|
||||
@@ -652,6 +710,63 @@ def _print_success(console: Console, record: dict[str, Any]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _onboarding_state(guidance: dict[str, Any]) -> dict[str, Any]:
|
||||
onboarding = guidance.get("onboarding")
|
||||
if not isinstance(onboarding, dict):
|
||||
return {}
|
||||
return cast("dict[str, Any]", onboarding)
|
||||
|
||||
|
||||
def _github_install_url(guidance: dict[str, Any]) -> str | None:
|
||||
url = _onboarding_state(guidance).get("github_install_url")
|
||||
return url if is_safe_web_url(url) else None
|
||||
|
||||
|
||||
def _print_next_steps(console: Console, guidance: dict[str, Any]) -> None:
|
||||
"""Render the server-provided setup steps for a person."""
|
||||
steps = [
|
||||
step
|
||||
for step in _dict_items(guidance.get("next_steps"))
|
||||
if isinstance(step.get("action"), str) and step.get("action")
|
||||
]
|
||||
github_url = _github_install_url(guidance)
|
||||
if not steps and github_url is None:
|
||||
return
|
||||
console.print()
|
||||
console.print("[bold]Next steps:[/]")
|
||||
for index, step in enumerate(steps, start=1):
|
||||
console.print(f" [cyan]{index}[/]. {_terminal_markup(step['action'])}")
|
||||
cli = step.get("cli")
|
||||
if isinstance(cli, str) and cli:
|
||||
console.print(f" [dim]{_terminal_markup(cli)}[/]")
|
||||
if github_url is not None:
|
||||
console.print()
|
||||
console.print("Install the Strix GitHub App to connect repositories:")
|
||||
console.print(sanitize_terminal_text(github_url), markup=False, soft_wrap=True)
|
||||
|
||||
|
||||
def _offer_github_install(
|
||||
console: Console, guidance: dict[str, Any], *, open_browser: bool
|
||||
) -> None:
|
||||
"""On a first sign-in in a terminal, offer to open the GitHub App installation page."""
|
||||
github_url = _github_install_url(guidance)
|
||||
if github_url is None or not open_browser or not guidance.get("is_new_user"):
|
||||
return
|
||||
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
||||
return
|
||||
console.print()
|
||||
try:
|
||||
answer = console.input("Open the GitHub App installation page now? [y/N]: ").strip()
|
||||
except EOFError:
|
||||
return
|
||||
if answer.casefold() not in {"y", "yes"}:
|
||||
console.print("[dim]Run `strix cloud integrations install github` when you are ready.[/]")
|
||||
return
|
||||
with contextlib.suppress(Exception):
|
||||
webbrowser.open(github_url)
|
||||
console.print("[dim]Approve the installation in the browser. Then run `strix cloud repos`.[/]")
|
||||
|
||||
|
||||
def _status(console: Console, argv: list[str]) -> int: # noqa: PLR0912
|
||||
parser = _SessionArgumentParser(
|
||||
prog="strix cloud whoami",
|
||||
|
||||
@@ -15,6 +15,7 @@ from agents import RunContextWrapper, function_tool
|
||||
from strix.core.agents import Status, coordinator_from_context
|
||||
from strix.core.execution import notify_parent_on_terminal
|
||||
from strix.core.hooks import LLM_TURN_KEY
|
||||
from strix.report.state import get_global_report_state
|
||||
from strix.skills import validate_requested_skills
|
||||
|
||||
|
||||
@@ -28,6 +29,40 @@ def _ctx(ctx: RunContextWrapper) -> dict[str, Any]:
|
||||
return ctx.context if isinstance(ctx.context, dict) else {}
|
||||
|
||||
|
||||
def _filed_reports_by(agent_id: str) -> list[dict[str, Any]]:
|
||||
"""Vulnerability reports the agent actually filed, from report state.
|
||||
|
||||
The narrative ``findings`` an agent hands to ``agent_finish`` is prose; a
|
||||
parent that wants to act on a child's work needs the report ids. Read them
|
||||
from the report state rather than trusting the child's description.
|
||||
"""
|
||||
state = get_global_report_state()
|
||||
if state is None:
|
||||
return []
|
||||
filed: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for report in state.get_existing_vulnerabilities():
|
||||
if report.get("agent_id") != agent_id:
|
||||
continue
|
||||
report_id = str(report.get("id") or "")
|
||||
if not report_id or report_id in seen:
|
||||
continue
|
||||
seen.add(report_id)
|
||||
filed.append(report)
|
||||
return filed
|
||||
|
||||
|
||||
def _render_filed_report(report: dict[str, Any]) -> str:
|
||||
line = f"- {report.get('id')}"
|
||||
severity = report.get("severity")
|
||||
if severity:
|
||||
line += f" [{str(severity).upper()}]"
|
||||
title = report.get("title")
|
||||
if title:
|
||||
line += f" {title}"
|
||||
return line
|
||||
|
||||
|
||||
def _render_completion_report(
|
||||
*,
|
||||
agent_name: str,
|
||||
@@ -38,6 +73,7 @@ def _render_completion_report(
|
||||
findings: list[str],
|
||||
recommendations: list[str],
|
||||
open_items: list[str],
|
||||
filed_reports: list[dict[str, Any]] | None = None,
|
||||
) -> str:
|
||||
"""Render a child's completion report as plain structured text.
|
||||
|
||||
@@ -63,6 +99,12 @@ def _render_completion_report(
|
||||
lines.append("Findings:")
|
||||
lines.extend(f"- {f}" for f in findings)
|
||||
lines.append("")
|
||||
lines.append("Vulnerability reports filed by this agent (authoritative; use these ids):")
|
||||
if filed_reports:
|
||||
lines.extend(_render_filed_report(r) for r in filed_reports)
|
||||
else:
|
||||
lines.append("- (none)")
|
||||
lines.append("")
|
||||
lines.append("Open items (unresolved, need follow-up):")
|
||||
if open_items:
|
||||
lines.extend(f"- {o}" for o in open_items)
|
||||
@@ -149,8 +191,11 @@ async def send_message_to_agent(
|
||||
**Don't** use for routine "hello/status" pings, for context the
|
||||
target already has (children inherit parent history), or when
|
||||
parent/child completion via ``agent_finish`` already covers the
|
||||
flow. Messages to any registered agent wake it, regardless of
|
||||
flow. In interactive runs a message wakes the target regardless of
|
||||
status, so a follow-up can restart a completed/stopped/failed agent.
|
||||
In non-interactive runs a finished agent is gone for good: the call
|
||||
fails with the target's status, and you should read its filed
|
||||
reports (``list_reports``) or spawn a new agent instead of waiting.
|
||||
|
||||
Args:
|
||||
target_agent_id: Recipient's 8-char id.
|
||||
@@ -195,10 +240,23 @@ async def send_message_to_agent(
|
||||
},
|
||||
)
|
||||
if not delivered:
|
||||
_, status = await coordinator.reachability(target_agent_id)
|
||||
if status is None:
|
||||
error = f"Target agent '{target_agent_id}' not found"
|
||||
else:
|
||||
error = (
|
||||
f"Target agent '{target_agent_id}' is '{status}' and cannot be woken in "
|
||||
"this run; it will never read this message. Its filed reports are in "
|
||||
"list_reports / get_report. Do not wait_for_agents on it - spawn a new "
|
||||
"agent if more work is needed."
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Target agent '{target_agent_id}' not found or message delivery failed",
|
||||
"error": error,
|
||||
"target_agent_id": target_agent_id,
|
||||
"target_status": status,
|
||||
"delivery_status": "not_delivered",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
@@ -364,6 +422,31 @@ async def wait_for_agents( # noqa: PLR0911
|
||||
default=str,
|
||||
)
|
||||
|
||||
# Non-interactive agents cannot be woken once terminal, so with nobody
|
||||
# running or waiting there is no message left to wait for.
|
||||
if not await coordinator.active_agents_except(me):
|
||||
_, statuses, names, _ = await coordinator.graph_snapshot()
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"wait_outcome": "no_active_agents",
|
||||
"reason": reason,
|
||||
"agents": [
|
||||
{"agent_id": aid, "name": names.get(aid, aid), "status": status}
|
||||
for aid, status in statuses.items()
|
||||
if aid != me
|
||||
],
|
||||
"note": (
|
||||
"No other agent is running or waiting, so no message can arrive. "
|
||||
"Finished agents' results are in list_reports / get_report and their "
|
||||
"completion reports are already in your history. Continue your own "
|
||||
"work, spawn a new agent, or finish."
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
await coordinator.park_waiting(me, wait_kind="agents")
|
||||
try:
|
||||
await asyncio.wait_for(coordinator.wait_for_message(me), timeout_seconds)
|
||||
@@ -610,6 +693,9 @@ async def agent_finish(
|
||||
default=str,
|
||||
)
|
||||
|
||||
filed_reports = _filed_reports_by(me)
|
||||
filed_report_ids = [str(r.get("id")) for r in filed_reports]
|
||||
|
||||
parent_notified = False
|
||||
if report_to_parent and await coordinator.claim_parent_notice(me):
|
||||
async with coordinator._lock:
|
||||
@@ -623,6 +709,7 @@ async def agent_finish(
|
||||
findings=list(findings or []),
|
||||
recommendations=list(final_recommendations or []),
|
||||
open_items=list(open_items or []),
|
||||
filed_reports=filed_reports,
|
||||
)
|
||||
await coordinator.send(
|
||||
parent_id,
|
||||
@@ -632,6 +719,7 @@ async def agent_finish(
|
||||
"content": report,
|
||||
"type": "completion",
|
||||
"priority": "high",
|
||||
"filed_report_ids": filed_report_ids,
|
||||
},
|
||||
)
|
||||
parent_notified = True
|
||||
@@ -642,10 +730,11 @@ async def agent_finish(
|
||||
await notify_parent_on_terminal(coordinator, me, "completed")
|
||||
|
||||
logger.info(
|
||||
"agent_finish: %s success=%s findings=%d parent_notified=%s",
|
||||
"agent_finish: %s success=%s findings=%d filed_reports=%d parent_notified=%s",
|
||||
me,
|
||||
success,
|
||||
len(findings or []),
|
||||
len(filed_report_ids),
|
||||
parent_notified,
|
||||
)
|
||||
|
||||
@@ -656,6 +745,7 @@ async def agent_finish(
|
||||
"parent_notified": parent_notified,
|
||||
"agent_id": me,
|
||||
"summary": result_summary,
|
||||
"filed_report_ids": filed_report_ids,
|
||||
"findings_count": len(findings or []),
|
||||
"open_items_count": len(open_items or []),
|
||||
"has_recommendations": bool(final_recommendations),
|
||||
|
||||
258
tests/test_agent_graph_coordination.py
Normal file
258
tests/test_agent_graph_coordination.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""Tests for parent/child coordination once a non-interactive child has finished.
|
||||
|
||||
A non-interactive agent's loop returns after its terminal state, so nothing will
|
||||
ever read a message sent to it afterwards. Messaging it must say so instead of
|
||||
reporting delivery, waiting on it must return at once, and its completion report
|
||||
must carry the ids of the reports it actually filed so the parent does not have
|
||||
to go asking.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import pytest
|
||||
from agents.tool_context import ToolContext
|
||||
|
||||
from strix.core.agents import AgentCoordinator
|
||||
from strix.report.state import ReportState, set_global_report_state
|
||||
from strix.tools.agents_graph.tools import agent_finish, send_message_to_agent, wait_for_agents
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def report_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[ReportState]:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
state = ReportState(run_name="test-run")
|
||||
set_global_report_state(state)
|
||||
yield state
|
||||
set_global_report_state(None)
|
||||
|
||||
|
||||
async def _graph(*, interactive: bool) -> AgentCoordinator:
|
||||
coordinator = AgentCoordinator()
|
||||
await coordinator.register("root", "strix", parent_id=None)
|
||||
await coordinator.register("child", "Validator", parent_id="root")
|
||||
await coordinator.attach_runtime("root", resumable=interactive)
|
||||
await coordinator.attach_runtime("child", resumable=interactive)
|
||||
return coordinator
|
||||
|
||||
|
||||
async def _call(
|
||||
tool: Any, coordinator: AgentCoordinator, agent_id: str, args: dict[str, Any], **extra: Any
|
||||
) -> dict[str, Any]:
|
||||
ctx = ToolContext(
|
||||
context={"coordinator": coordinator, "agent_id": agent_id, **extra},
|
||||
tool_name=tool.name,
|
||||
tool_call_id="call-1",
|
||||
tool_arguments="{}",
|
||||
)
|
||||
raw: str = await tool.on_invoke_tool(ctx, json.dumps(args))
|
||||
return cast("dict[str, Any]", json.loads(raw))
|
||||
|
||||
|
||||
# --- send_message_to_agent -------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_to_finished_non_interactive_child_is_not_delivered() -> None:
|
||||
coordinator = await _graph(interactive=False)
|
||||
await coordinator.set_status("child", "completed")
|
||||
|
||||
result = await _call(
|
||||
send_message_to_agent,
|
||||
coordinator,
|
||||
"root",
|
||||
{"target_agent_id": "child", "message": "did you file it?", "message_type": "query"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["delivery_status"] == "not_delivered"
|
||||
assert result["target_status"] == "completed"
|
||||
assert "list_reports" in result["error"]
|
||||
assert coordinator.pending_counts.get("child", 0) == 0
|
||||
assert coordinator.runtimes["child"].mailbox == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", ["stopped", "failed", "crashed"])
|
||||
async def test_every_terminal_non_interactive_status_is_unreachable(status: str) -> None:
|
||||
coordinator = await _graph(interactive=False)
|
||||
await coordinator.set_status("child", status)
|
||||
|
||||
assert await coordinator.send("child", {"from": "root", "content": "hi"}) is False
|
||||
assert await coordinator.reachability("child") == (False, status)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", ["running", "waiting"])
|
||||
async def test_message_to_live_child_is_delivered(status: str) -> None:
|
||||
coordinator = await _graph(interactive=False)
|
||||
await coordinator.set_status("child", status)
|
||||
|
||||
result = await _call(
|
||||
send_message_to_agent,
|
||||
coordinator,
|
||||
"root",
|
||||
{"target_agent_id": "child", "message": "wrap up"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["delivery_status"] == "delivered"
|
||||
assert coordinator.pending_counts["child"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_to_finished_interactive_child_still_wakes_it() -> None:
|
||||
# An interactive loop parks after finishing and resumes on a message.
|
||||
coordinator = await _graph(interactive=True)
|
||||
await coordinator.set_status("child", "completed")
|
||||
|
||||
result = await _call(
|
||||
send_message_to_agent,
|
||||
coordinator,
|
||||
"root",
|
||||
{"target_agent_id": "child", "message": "one more thing"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert coordinator.pending_counts["child"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_target_is_reported_as_not_found() -> None:
|
||||
coordinator = await _graph(interactive=False)
|
||||
|
||||
result = await _call(
|
||||
send_message_to_agent,
|
||||
coordinator,
|
||||
"root",
|
||||
{"target_agent_id": "ghost", "message": "hello"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["target_status"] is None
|
||||
assert "not found" in result["error"]
|
||||
|
||||
|
||||
# --- wait_for_agents -------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_returns_at_once_when_no_child_can_answer() -> None:
|
||||
coordinator = await _graph(interactive=False)
|
||||
await coordinator.set_status("child", "completed")
|
||||
# The completion report was already consumed in an earlier turn.
|
||||
|
||||
result = await _call(
|
||||
wait_for_agents,
|
||||
coordinator,
|
||||
"root",
|
||||
{"reason": "waiting for validator", "timeout_seconds": 240},
|
||||
)
|
||||
|
||||
assert result["wait_outcome"] == "no_active_agents"
|
||||
assert result["agents"] == [{"agent_id": "child", "name": "Validator", "status": "completed"}]
|
||||
assert coordinator.statuses["root"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_delivers_a_pending_report_before_checking_liveness() -> None:
|
||||
coordinator = await _graph(interactive=False)
|
||||
await coordinator.send("root", {"from": "child", "type": "completion", "content": "done"})
|
||||
await coordinator.set_status("child", "completed")
|
||||
|
||||
result = await _call(wait_for_agents, coordinator, "root", {"timeout_seconds": 5})
|
||||
|
||||
assert result["wait_outcome"] == "message_arrived"
|
||||
assert result["pending_messages"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_still_parks_while_a_child_is_running() -> None:
|
||||
coordinator = await _graph(interactive=False)
|
||||
|
||||
result = await _call(wait_for_agents, coordinator, "root", {"timeout_seconds": 1})
|
||||
|
||||
assert result["wait_outcome"] == "timeout"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interactive_wait_parks_even_without_active_children() -> None:
|
||||
# In an interactive run a finished child can be woken later, so parking is
|
||||
# legitimate; the run loop's own auto-resume bounds the wait.
|
||||
coordinator = await _graph(interactive=True)
|
||||
await coordinator.set_status("child", "completed")
|
||||
|
||||
result = await _call(
|
||||
wait_for_agents, coordinator, "root", {"timeout_seconds": 5}, interactive=True
|
||||
)
|
||||
|
||||
assert result["wait_outcome"] == "waiting"
|
||||
|
||||
|
||||
# --- agent_finish ----------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_finish_lists_the_reports_the_child_filed(report_state: ReportState) -> None:
|
||||
coordinator = await _graph(interactive=False)
|
||||
mine = report_state.add_vulnerability_report(
|
||||
title="IDOR on /api/audits", severity="high", agent_id="child", agent_name="Validator"
|
||||
)
|
||||
report_state.add_vulnerability_report(title="Root's own", severity="low", agent_id="root")
|
||||
|
||||
result = await _call(
|
||||
agent_finish,
|
||||
coordinator,
|
||||
"child",
|
||||
{"result_summary": "confirmed", "findings": ["IDOR confirmed"]},
|
||||
parent_id="root",
|
||||
)
|
||||
|
||||
assert result["filed_report_ids"] == [mine]
|
||||
delivered = coordinator.runtimes["root"].mailbox
|
||||
assert len(delivered) == 1
|
||||
assert delivered[0]["filed_report_ids"] == [mine]
|
||||
body = delivered[0]["content"]
|
||||
assert f"- {mine} [HIGH] IDOR on /api/audits" in body
|
||||
assert "Root's own" not in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_finish_states_explicitly_when_nothing_was_filed(
|
||||
report_state: ReportState,
|
||||
) -> None:
|
||||
coordinator = await _graph(interactive=False)
|
||||
report_state.add_vulnerability_report(title="Someone else's", severity="low", agent_id="root")
|
||||
|
||||
result = await _call(
|
||||
agent_finish,
|
||||
coordinator,
|
||||
"child",
|
||||
{"result_summary": "nothing exploitable", "findings": ["ruled out X"]},
|
||||
parent_id="root",
|
||||
)
|
||||
|
||||
assert result["filed_report_ids"] == []
|
||||
body = coordinator.runtimes["root"].mailbox[0]["content"]
|
||||
assert "Vulnerability reports filed by this agent" in body
|
||||
assert body.index("filed by this agent") < body.index("- (none)")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_finish_without_report_state_still_completes() -> None:
|
||||
set_global_report_state(None)
|
||||
coordinator = await _graph(interactive=False)
|
||||
|
||||
result = await _call(
|
||||
agent_finish, coordinator, "child", {"result_summary": "done"}, parent_id="root"
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["filed_report_ids"] == []
|
||||
@@ -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 <count>" 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 <count>" 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
|
||||
|
||||
@@ -7,6 +7,7 @@ import io
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import webbrowser
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
@@ -862,6 +863,281 @@ def test_login_workspace_selector_prefers_ids_and_rejects_duplicate_names() -> N
|
||||
platform_cli._choose_workspace(console, organizations, "Strix")
|
||||
|
||||
|
||||
_NEW_USER_ONBOARDING: dict[str, Any] = {
|
||||
"workspace_named": False,
|
||||
"repositories_connected": False,
|
||||
"domains_added": False,
|
||||
"github_install_url": "https://github.com/apps/strix/installations/new?state=abc",
|
||||
}
|
||||
|
||||
_NEW_USER_LOGIN: dict[str, Any] = {
|
||||
"api_token": "strix_pat_test",
|
||||
"email": "alex@example.com",
|
||||
"organization_id": "org-1",
|
||||
"organization_name": "Alex's Workspace",
|
||||
"scopes": ["scans:read"],
|
||||
"is_new_user": True,
|
||||
"onboarding": _NEW_USER_ONBOARDING,
|
||||
"next_steps_hint": "Run the steps in order.",
|
||||
"next_steps": [
|
||||
{
|
||||
"action": "Name your workspace",
|
||||
"cli": 'strix cloud org update --name "Acme Security"',
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/organization",
|
||||
},
|
||||
{
|
||||
"action": "Add a domain",
|
||||
"cli": "strix cloud domains add --domain example.com --asset-type web_app",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/domains",
|
||||
},
|
||||
],
|
||||
"dashboard_url": "https://app.strix.ai",
|
||||
}
|
||||
|
||||
|
||||
def test_login_guidance_is_shown_once_and_never_stored() -> None:
|
||||
stored, guidance = platform_cli._split_login_guidance(dict(_NEW_USER_LOGIN))
|
||||
|
||||
assert stored["api_token"] == _NEW_USER_LOGIN["api_token"]
|
||||
assert stored["organization_name"] == "Alex's Workspace"
|
||||
assert set(guidance) == {
|
||||
"is_new_user",
|
||||
"onboarding",
|
||||
"next_steps",
|
||||
"next_steps_hint",
|
||||
"dashboard_url",
|
||||
}
|
||||
assert not any(key in stored for key in guidance)
|
||||
|
||||
|
||||
def test_new_user_success_renders_setup_steps_and_github_link() -> None:
|
||||
output = io.StringIO()
|
||||
console = Console(file=output, width=120)
|
||||
stored, guidance = platform_cli._split_login_guidance(dict(_NEW_USER_LOGIN))
|
||||
|
||||
platform_cli._print_success(console, stored, guidance)
|
||||
|
||||
rendered = output.getvalue()
|
||||
assert "Welcome to Strix" in rendered
|
||||
assert "Alex's Workspace" in rendered
|
||||
assert "(default name)" in rendered
|
||||
assert "Next steps:" in rendered
|
||||
assert "1. Name your workspace" in rendered
|
||||
assert 'strix cloud org update --name "Acme Security"' in rendered
|
||||
assert "2. Add a domain" in rendered
|
||||
assert "https://github.com/apps/strix/installations/new?state=abc" in rendered
|
||||
|
||||
|
||||
def test_returning_user_success_stays_compact_without_guidance() -> None:
|
||||
output = io.StringIO()
|
||||
console = Console(file=output, width=120)
|
||||
|
||||
platform_cli._print_success(
|
||||
console,
|
||||
{"email": "alex@example.com", "organization_name": "Acme", "scopes": ["scans:read"]},
|
||||
)
|
||||
|
||||
rendered = output.getvalue()
|
||||
assert "Signed in to the Strix platform." in rendered
|
||||
assert "Welcome" not in rendered
|
||||
assert "Next steps" not in rendered
|
||||
assert "default name" not in rendered
|
||||
|
||||
|
||||
def test_success_ignores_unsafe_github_link_and_malformed_steps() -> None:
|
||||
output = io.StringIO()
|
||||
console = Console(file=output, width=120)
|
||||
|
||||
platform_cli._print_success(
|
||||
console,
|
||||
{"organization_name": "Acme"},
|
||||
{
|
||||
"onboarding": {"github_install_url": "javascript:alert(1)"},
|
||||
"next_steps": ["not a step", {"cli": "strix cloud"}],
|
||||
},
|
||||
)
|
||||
|
||||
rendered = output.getvalue()
|
||||
assert "javascript:" not in rendered
|
||||
assert "Next steps" not in rendered
|
||||
|
||||
|
||||
def test_first_login_prompts_for_a_workspace_name_and_sends_it(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
|
||||
sent: dict[str, Any] = {}
|
||||
|
||||
def post(_url: str, **kwargs: Any) -> FakeResponse:
|
||||
sent.update(kwargs["json"])
|
||||
return FakeResponse(_NEW_USER_LOGIN)
|
||||
|
||||
monkeypatch.setattr(requests, "post", post)
|
||||
console = Console(file=io.StringIO(), width=120)
|
||||
answers = iter(["Acme Security", "1"])
|
||||
console.input = lambda *_args, **_kwargs: next(answers) # type: ignore[method-assign]
|
||||
|
||||
record = platform_cli._complete_selection(
|
||||
console,
|
||||
"https://example.test",
|
||||
{
|
||||
"selection_token": "sel-1",
|
||||
"is_new_user": True,
|
||||
"organizations": [
|
||||
{
|
||||
"id": "org-1",
|
||||
"name": "Alex's Workspace",
|
||||
"role": "admin",
|
||||
"has_default_name": True,
|
||||
}
|
||||
],
|
||||
"scopes": [{"scope": "scans:read", "min_role": "viewer", "minimum": True}],
|
||||
},
|
||||
scopes=None,
|
||||
scope_profile=None,
|
||||
workspace=None,
|
||||
)
|
||||
|
||||
assert sent["workspace_name"] == "Acme Security"
|
||||
assert sent["organization_id"] == "org-1"
|
||||
assert record["api_token"] == _NEW_USER_LOGIN["api_token"]
|
||||
|
||||
|
||||
def test_first_login_keeps_the_default_name_on_enter(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
|
||||
sent: dict[str, Any] = {}
|
||||
|
||||
def post(_url: str, **kwargs: Any) -> FakeResponse:
|
||||
sent.update(kwargs["json"])
|
||||
return FakeResponse(_NEW_USER_LOGIN)
|
||||
|
||||
monkeypatch.setattr(requests, "post", post)
|
||||
console = Console(file=io.StringIO(), width=120)
|
||||
console.input = lambda *_args, **_kwargs: "" # type: ignore[method-assign]
|
||||
|
||||
platform_cli._complete_selection(
|
||||
console,
|
||||
"https://example.test",
|
||||
{
|
||||
"selection_token": "sel-1",
|
||||
"is_new_user": True,
|
||||
"organizations": [
|
||||
{
|
||||
"id": "org-1",
|
||||
"name": "Alex's Workspace",
|
||||
"role": "admin",
|
||||
"has_default_name": True,
|
||||
}
|
||||
],
|
||||
"scopes": [],
|
||||
},
|
||||
scopes=None,
|
||||
scope_profile="recommended",
|
||||
workspace=None,
|
||||
)
|
||||
|
||||
assert "workspace_name" not in sent
|
||||
|
||||
|
||||
def test_workspace_name_prompt_rejects_overlong_names() -> None:
|
||||
output = io.StringIO()
|
||||
console = Console(file=output, width=120)
|
||||
answers = iter(["x" * 101, "Acme"])
|
||||
console.input = lambda *_args, **_kwargs: next(answers) # type: ignore[method-assign]
|
||||
|
||||
assert platform_cli._choose_workspace_name(console, "Alex's Workspace") == "Acme"
|
||||
assert "100 characters or less" in output.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("is_new_user", "has_default_name", "tty"),
|
||||
[(False, True, True), (True, False, True), (True, True, False)],
|
||||
)
|
||||
def test_workspace_name_prompt_is_skipped_for_returning_named_or_noninteractive_logins(
|
||||
monkeypatch: pytest.MonkeyPatch, is_new_user: bool, has_default_name: bool, tty: bool
|
||||
) -> None:
|
||||
monkeypatch.setattr(sys.stdin, "isatty", lambda: tty)
|
||||
sent: dict[str, Any] = {}
|
||||
|
||||
def post(_url: str, **kwargs: Any) -> FakeResponse:
|
||||
sent.update(kwargs["json"])
|
||||
return FakeResponse(_NEW_USER_LOGIN)
|
||||
|
||||
monkeypatch.setattr(requests, "post", post)
|
||||
console = Console(file=io.StringIO(), width=120)
|
||||
console.input = lambda *_args, **_kwargs: pytest.fail("must not prompt") # type: ignore[method-assign]
|
||||
|
||||
platform_cli._complete_selection(
|
||||
console,
|
||||
"https://example.test",
|
||||
{
|
||||
"selection_token": "sel-1",
|
||||
"is_new_user": is_new_user,
|
||||
"organizations": [
|
||||
{
|
||||
"id": "org-1",
|
||||
"name": "Alex's Workspace",
|
||||
"role": "admin",
|
||||
"has_default_name": has_default_name,
|
||||
}
|
||||
],
|
||||
"scopes": [],
|
||||
},
|
||||
scopes=["scans:read"],
|
||||
scope_profile=None,
|
||||
workspace=None,
|
||||
)
|
||||
|
||||
assert "workspace_name" not in sent
|
||||
|
||||
|
||||
def test_github_install_offer_opens_the_browser_only_on_yes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
opened: list[str] = []
|
||||
|
||||
def fake_open(url: str) -> bool:
|
||||
opened.append(url)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(webbrowser, "open", fake_open)
|
||||
guidance: dict[str, Any] = {"is_new_user": True, "onboarding": dict(_NEW_USER_ONBOARDING)}
|
||||
|
||||
output = io.StringIO()
|
||||
console = Console(file=output, width=120)
|
||||
console.input = lambda *_args, **_kwargs: "n" # type: ignore[method-assign]
|
||||
platform_cli._offer_github_install(console, guidance, open_browser=True)
|
||||
assert opened == []
|
||||
assert "strix cloud integrations install github" in output.getvalue()
|
||||
|
||||
console.input = lambda *_args, **_kwargs: "y" # type: ignore[method-assign]
|
||||
platform_cli._offer_github_install(console, guidance, open_browser=True)
|
||||
assert opened == ["https://github.com/apps/strix/installations/new?state=abc"]
|
||||
|
||||
|
||||
def test_github_install_offer_never_prompts_without_a_terminal_or_browser(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(webbrowser, "open", lambda _url: pytest.fail("must not open"))
|
||||
guidance: dict[str, Any] = {"is_new_user": True, "onboarding": dict(_NEW_USER_ONBOARDING)}
|
||||
console = Console(file=io.StringIO(), width=120)
|
||||
console.input = lambda *_args, **_kwargs: pytest.fail("must not prompt") # type: ignore[method-assign]
|
||||
|
||||
monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
|
||||
platform_cli._offer_github_install(console, guidance, open_browser=True)
|
||||
|
||||
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
platform_cli._offer_github_install(console, guidance, open_browser=False)
|
||||
platform_cli._offer_github_install(
|
||||
console, {**guidance, "is_new_user": False}, open_browser=True
|
||||
)
|
||||
|
||||
|
||||
def test_device_flow_slow_down_never_exceeds_the_poll_interval_cap(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -41,6 +41,8 @@ def _fast_wait(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
async def _context() -> dict[str, Any]:
|
||||
coordinator = AgentCoordinator()
|
||||
await coordinator.register("root", "strix", parent_id=None)
|
||||
# A live child keeps the wait genuine: with nobody to hear from it returns at once.
|
||||
await coordinator.register("child", "recon", parent_id="root")
|
||||
return {"agent_id": "root", "coordinator": coordinator}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user