Compare commits

..

8 Commits

Author SHA1 Message Date
Alex Schapiro
9d14cf96bd feat(cloud): guide first-time users through workspace setup after sign-in
Show the server-provided next steps after every sign-in. On a first sign-in
in a terminal, ask for a workspace name and offer to open the GitHub App
installation page. The one-time guidance is never written to the credential
file, and there are no prompts without a TTY or with --json.
2026-09-03 04:52:23 +00:00
alex s
5d015df6b1 fix(cloud): print top-up instructions on 402 and guide oversize or archive --source (#1242)
- Every payment-required error now ends with a "Next step" line: the
  platform hint when one is sent, else the topup command and the billing
  URL for the configured platform. JSON output gets the same text as
  next_step. The platform hint is no longer repeated inside the error.
- An archive file passed to --source is rejected with guidance to pass
  the directory instead, which packs and excludes deps/build output.
- An oversize archive names its largest files and points to --exclude
  and --dry-run --show-files.
- uploads request help points to scans start --source for local code.
2026-09-02 15:26:37 -04:00
Ahmed Allam
1edafd3e80 fix(agents): stop parents waiting on finished non-interactive children
A non-interactive agent's loop returns after its terminal state, yet
send_message_to_agent kept reporting messages to it as delivered and the
parent then waited out wait_for_agents on a reply that could never come.

- AgentRuntime.resumable records whether the loop parks for wake-ups after a
  terminal state; run_agent_loop / _start_child_runner set it from interactive.
- AgentCoordinator.send returns False (nothing queued) for a terminal agent
  that is not resumable; send_message_to_agent surfaces target_status and
  delivery_status=not_delivered with a pointer to list_reports / get_report.
- wait_for_agents returns wait_outcome=no_active_agents at once when no other
  agent is running or waiting in a non-interactive run.
- agent_finish reads the reports the finishing agent filed from the report
  state and puts their ids in the completion report, the parent message
  (filed_report_ids) and its own return payload, so parents no longer have to
  infer what was filed from prose.
2026-09-02 22:11:12 +03:00
Ahmed Allam
f1e24fe3f2 chore: release v1.6.1 2026-09-02 19:05:46 +03:00
Ahmed Allam
e644f4a02c docs(readme): shorten the coding-agent skills paragraph 2026-09-02 18:46:33 +03:00
Ahmed Allam
1ebe1007e8 docs: keep the existing recommended model rows in the README 2026-09-02 18:35:45 +03:00
Ahmed Allam
53d2e5cfeb docs: note viewer steering, history, and report prerequisites 2026-09-02 18:35:45 +03:00
Ahmed Allam
7708f717d5 docs: trim crammed README sections and add cloud CLI and viewer docs pages 2026-09-02 18:35:45 +03:00
17 changed files with 995 additions and 27 deletions

View File

@@ -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.
---

View File

@@ -17,6 +17,8 @@ strix cloud session # verify the remote session and consen
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

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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:

View File

@@ -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,
*,

View File

@@ -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."),

View File

@@ -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",

View File

@@ -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),

View 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"] == []

View File

@@ -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

View File

@@ -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:

View File

@@ -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"))

View File

@@ -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}

2
uv.lock generated
View File

@@ -2378,7 +2378,7 @@ wheels = [
[[package]]
name = "strix-agent"
version = "1.6.0"
version = "1.6.1"
source = { editable = "." }
dependencies = [
{ name = "caido-sdk-client" },