mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 17:27:26 +02:00
Add live agent steering from the web viewer
Let the in-TUI viewer send a steering message to a running agent during a live scan, reusing the same delivery path the TUI uses. Standalone strix view reports steering unavailable, so the web composer only shows when a live scan is in process.
This commit is contained in:
@@ -1844,7 +1844,19 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
self._set_viewer_cta("[#eab308]Viewer UI not built[/]")
|
||||
return
|
||||
run_dir = self.report_state.get_run_dir()
|
||||
httpd, url = serve(run_dir, open_browser=True)
|
||||
|
||||
def _viewer_steer(agent_id: str, message: str) -> bool:
|
||||
# Reuse the exact TUI delivery path, but target the agent the
|
||||
# web graph selected (not the TUI's current selection).
|
||||
return send_user_message_to_agent(
|
||||
coordinator=self.coordinator,
|
||||
loop=self._scan_loop,
|
||||
live_view=self.live_view,
|
||||
target_agent_id=agent_id,
|
||||
message=message,
|
||||
)
|
||||
|
||||
httpd, url = serve(run_dir, open_browser=True, steer_handler=_viewer_steer)
|
||||
except Exception:
|
||||
logger.debug("failed to start local viewer", exc_info=True)
|
||||
self._set_viewer_cta("[red]Viewer failed to start[/]")
|
||||
|
||||
+59
-3
@@ -22,7 +22,7 @@ import webbrowser
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import parse_qs, unquote, urlsplit
|
||||
|
||||
from strix.core.paths import run_record_path
|
||||
@@ -37,6 +37,10 @@ from strix.viewer.transcript import (
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -103,12 +107,21 @@ def resolve_run_dir(base_dir: Path, run_param: str | None, default_run_dir: Path
|
||||
|
||||
|
||||
class _ViewerState:
|
||||
def __init__(self, run_dir: Path, assets_dir: Path) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
run_dir: Path,
|
||||
assets_dir: Path,
|
||||
steer_handler: Callable[[str, str], bool] | None = None,
|
||||
) -> None:
|
||||
self.run_dir = run_dir
|
||||
self.assets_dir = assets_dir
|
||||
# The strix_runs directory that holds the launched run; used to
|
||||
# enumerate and resolve other runs for the history list.
|
||||
self.base_dir = run_dir.parent
|
||||
# Set only when the viewer runs inside a live scan process (the TUI
|
||||
# launcher), which can deliver a message to a running agent. Absent for
|
||||
# standalone ``strix view`` / finished runs, so steering is unavailable.
|
||||
self.steer_handler = steer_handler
|
||||
|
||||
|
||||
def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
@@ -148,6 +161,8 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
self._handle_forget()
|
||||
elif path == "/api/report/send":
|
||||
self._handle_report_send()
|
||||
elif path == "/api/agents/steer":
|
||||
self._handle_steer()
|
||||
else:
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown endpoint"})
|
||||
except BrokenPipeError:
|
||||
@@ -204,6 +219,13 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
payload = build_runs_payload(state.base_dir, verified=auth.is_verified())
|
||||
self._send_json(HTTPStatus.OK, payload)
|
||||
return
|
||||
if path == "/api/capabilities":
|
||||
# Steering is only possible when the viewer shares a live scan's
|
||||
# coordinator + event loop (the TUI launcher wires a handler).
|
||||
self._send_json(
|
||||
HTTPStatus.OK, {"can_steer": state.steer_handler is not None}
|
||||
)
|
||||
return
|
||||
if path == "/api/auth/status":
|
||||
record = auth.read_auth()
|
||||
self._send_json(
|
||||
@@ -299,6 +321,35 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
{"ok": True, "password": password, "filename": filename},
|
||||
)
|
||||
|
||||
# Cap on a steering message so a runaway client cannot flood the agent.
|
||||
_STEER_MESSAGE_MAX = 4000
|
||||
|
||||
def _handle_steer(self) -> None:
|
||||
body = self._read_body()
|
||||
agent_id = body.get("agent_id")
|
||||
message = body.get("message")
|
||||
if not isinstance(agent_id, str) or not agent_id.strip():
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_agent_id"})
|
||||
return
|
||||
if (
|
||||
not isinstance(message, str)
|
||||
or not message.strip()
|
||||
or len(message) > self._STEER_MESSAGE_MAX
|
||||
):
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_message"})
|
||||
return
|
||||
if state.steer_handler is None:
|
||||
# Standalone / finished-run viewing has no live scan to steer.
|
||||
self._send_json(
|
||||
HTTPStatus.FORBIDDEN, {"error": "steering_unavailable"}
|
||||
)
|
||||
return
|
||||
delivered = state.steer_handler(agent_id, message)
|
||||
if delivered:
|
||||
self._send_json(HTTPStatus.OK, {"ok": True})
|
||||
else:
|
||||
self._send_json(HTTPStatus.OK, {"ok": False, "error": "not_delivered"})
|
||||
|
||||
def _send_relay_error(self, exc: auth.RelayError) -> None:
|
||||
status_by_code = {
|
||||
"rate_limited": HTTPStatus.TOO_MANY_REQUESTS,
|
||||
@@ -359,15 +410,20 @@ def serve(
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 0,
|
||||
open_browser: bool = True,
|
||||
steer_handler: Callable[[str, str], bool] | None = None,
|
||||
) -> tuple[ThreadingHTTPServer, str]:
|
||||
"""Start the viewer server on a background thread and return (server, url).
|
||||
|
||||
Binds an ephemeral port by default. If a fixed ``port`` is requested but in
|
||||
use, falls back to an ephemeral port. Reused by both the ``strix view``
|
||||
command and the in-TUI launcher; callers own the server's lifetime.
|
||||
|
||||
``steer_handler`` is supplied only by the in-TUI launcher, which runs inside
|
||||
the live scan process and can forward a message to a running agent. Left
|
||||
``None`` (standalone ``strix view``), steering is reported unavailable.
|
||||
"""
|
||||
assets_dir = bundle_dir()
|
||||
state = _ViewerState(run_dir=run_dir, assets_dir=assets_dir)
|
||||
state = _ViewerState(run_dir=run_dir, assets_dir=assets_dir, steer_handler=steer_handler)
|
||||
handler = _make_handler(state)
|
||||
|
||||
try:
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+128
-123
File diff suppressed because one or more lines are too long
@@ -6,8 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Strix Results</title>
|
||||
<script type="module" crossorigin src="./assets/index-D9rN78-T.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CiotzpD9.css">
|
||||
<script type="module" crossorigin src="./assets/index-Cm9zWfr2.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CHD1Rail.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+104
-13
@@ -15,6 +15,7 @@ import {
|
||||
Radio,
|
||||
ArrowUpRight,
|
||||
History,
|
||||
Send,
|
||||
} from "lucide-react";
|
||||
import type { Vulnerability, VulnerabilitySeverity } from "@/types/issues";
|
||||
import { SEVERITY_COLORS } from "@/types/issues";
|
||||
@@ -29,16 +30,19 @@ import { severityCounts, type ParsedRunSummary } from "@/lib/local-run-parser";
|
||||
import {
|
||||
fetchAll,
|
||||
fetchAuthStatus,
|
||||
fetchCapabilities,
|
||||
fetchRunSummary,
|
||||
fetchRuns,
|
||||
fetchTranscript,
|
||||
fetchVulnerabilities,
|
||||
forgetAuth,
|
||||
steerAgent,
|
||||
type AuthStatus,
|
||||
type LoadedRun,
|
||||
type RunsPayload,
|
||||
type TranscriptAgent,
|
||||
} from "@/data/serverSource";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import { SIGNUP_URL, ctaUrl, track, trackCta } from "@/lib/cta";
|
||||
import { runTitle } from "@/lib/target-utils";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import PastRunsView from "@/components/PastRunsView";
|
||||
@@ -78,6 +82,9 @@ export default function App() {
|
||||
const [runs, setRuns] = useState<RunsPayload | null>(null);
|
||||
const [emailPurpose, setEmailPurpose] = useState<"report" | "verify">("report");
|
||||
const [emailSkipDisclosure, setEmailSkipDisclosure] = useState(false);
|
||||
// Whether this viewer can steer a live scan (true only inside the in-TUI
|
||||
// launcher that shares the running scan's coordinator + event loop).
|
||||
const [canSteer, setCanSteer] = useState(false);
|
||||
|
||||
const refreshAuth = useCallback(async () => {
|
||||
try {
|
||||
@@ -98,6 +105,12 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
void refreshAuth();
|
||||
void refreshRuns();
|
||||
// Capabilities never change over a session, so fetch once on mount.
|
||||
fetchCapabilities()
|
||||
.then((caps) => setCanSteer(caps.can_steer))
|
||||
.catch(() => {
|
||||
/* absence of steering is the safe default */
|
||||
});
|
||||
}, [refreshAuth, refreshRuns]);
|
||||
|
||||
// Live polling, scoped to the active run. Re-runs when the active run changes
|
||||
@@ -386,7 +399,7 @@ export default function App() {
|
||||
onOpenEmail={openEmailFromOverview}
|
||||
/>
|
||||
) : view === "agents" && agentCount > 0 ? (
|
||||
<AgentsTab run={run} />
|
||||
<AgentsTab run={run} canSteer={canSteer} />
|
||||
) : selected ? (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
@@ -729,7 +742,7 @@ function TabButton({
|
||||
);
|
||||
}
|
||||
|
||||
function AgentsTab({ run }: { run: LoadedRun }) {
|
||||
function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
|
||||
const { agents, events } = run.transcript;
|
||||
const graphAgents = useMemo(() => buildGraphAgents(agents, events), [agents, events]);
|
||||
// Clicking a graph node opens the agent's transcript in a modal (matching the
|
||||
@@ -737,6 +750,9 @@ function AgentsTab({ run }: { run: LoadedRun }) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const selectedAgent = selectedId ? (agents.find((a) => a.id === selectedId) ?? null) : null;
|
||||
|
||||
// Live steering is only possible in-process (canSteer) while the scan runs.
|
||||
const steerable = canSteer && !run.finished;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
@@ -762,18 +778,16 @@ function AgentsTab({ run }: { run: LoadedRun }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Steering footer: live control lives in Strix Cloud. */}
|
||||
{/* Live steering: only in-process while the scan runs. Otherwise omitted. */}
|
||||
{steerable && (
|
||||
<SteerComposer agents={agents} selectedId={selectedId} />
|
||||
)}
|
||||
|
||||
{/* Re-run always routes to Strix Cloud. */}
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<p className="text-sm font-semibold text-white">Drive the agents live</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">Steer and re-run this scan from the web.</p>
|
||||
<p className="text-sm font-semibold text-white">Run this scan with more depth</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">Re-run this scan on managed infra in the cloud.</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2.5">
|
||||
<ProInlineCta
|
||||
label="Steer agents from the web"
|
||||
desc="Guide the agents live while they scan."
|
||||
slug="live_scan_prompt"
|
||||
surface="agents"
|
||||
icon={Radio}
|
||||
/>
|
||||
<ProInlineCta
|
||||
label="Re-run in Strix Cloud with more depth"
|
||||
desc="Run this scan on managed infra with more depth."
|
||||
@@ -794,3 +808,80 @@ function AgentsTab({ run }: { run: LoadedRun }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Live steering composer. Targets the agent selected in the graph; with none
|
||||
// selected it falls back to the root agent (no parent). Only rendered while the
|
||||
// scan is live and the viewer runs in-process (see AgentsTab.steerable).
|
||||
function SteerComposer({
|
||||
agents,
|
||||
selectedId,
|
||||
}: {
|
||||
agents: TranscriptAgent[];
|
||||
selectedId: string | null;
|
||||
}) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
|
||||
const rootAgent = agents.find((a) => !a.parent_id) ?? agents[0] ?? null;
|
||||
const target =
|
||||
(selectedId ? (agents.find((a) => a.id === selectedId) ?? null) : null) ?? rootAgent;
|
||||
|
||||
const send = useCallback(async () => {
|
||||
const text = message.trim();
|
||||
if (!text || sending || !target) return;
|
||||
setSending(true);
|
||||
setFeedback(null);
|
||||
const res = await steerAgent(target.id, text);
|
||||
setSending(false);
|
||||
if (res.ok) {
|
||||
setMessage("");
|
||||
setFeedback(`Sent to ${target.name}`);
|
||||
track("agent_steered");
|
||||
} else if (res.error === "not_delivered") {
|
||||
setFeedback("Could not reach that agent (it may have finished).");
|
||||
} else {
|
||||
setFeedback("Could not send that message. Try again.");
|
||||
}
|
||||
}, [message, sending, target]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="w-4 h-4 text-[#888]" aria-hidden="true" />
|
||||
<h2 className="text-sm font-semibold text-white">Steer the agents</h2>
|
||||
</div>
|
||||
<p className="mt-1 mb-3 text-xs text-[#666]">
|
||||
Send a live instruction to{" "}
|
||||
<span className="text-[#aaa]">{target ? target.name : "the agent"}</span>
|
||||
{selectedId ? " (selected)" : " (root)"}. Click an agent in the graph to steer it directly.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. Focus on the authentication flow next"
|
||||
maxLength={4000}
|
||||
disabled={sending || !target}
|
||||
className="flex-1 min-w-0 rounded-lg border border-[#2a2a2a] bg-[rgba(255,255,255,0.03)] px-3 py-2 text-sm text-white placeholder:text-[#555] focus:outline-none focus:border-[#3a3a3a] disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={() => void send()}
|
||||
disabled={sending || !message.trim() || !target}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-2 text-sm font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
{sending ? "Sending" : "Send"}
|
||||
</button>
|
||||
</div>
|
||||
{feedback && <p className="mt-2 text-xs text-[#888]">{feedback}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,6 +179,28 @@ export async function fetchRuns(): Promise<RunsPayload> {
|
||||
};
|
||||
}
|
||||
|
||||
export interface Capabilities {
|
||||
can_steer: boolean;
|
||||
}
|
||||
|
||||
export type SteerResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
/** GET /api/capabilities. can_steer is true only inside a live in-TUI scan. */
|
||||
export async function fetchCapabilities(): Promise<Capabilities> {
|
||||
const obj = (await getJson("/api/capabilities")) as Partial<Capabilities>;
|
||||
return { can_steer: obj?.can_steer === true };
|
||||
}
|
||||
|
||||
/** POST /api/agents/steer. Sends a steering instruction to a running agent. */
|
||||
export async function steerAgent(agentId: string, message: string): Promise<SteerResult> {
|
||||
const { ok, data } = await postJson("/api/agents/steer", {
|
||||
agent_id: agentId,
|
||||
message,
|
||||
});
|
||||
if (ok && data.ok === true) return { ok: true };
|
||||
return { ok: false, error: String(data.error ?? "unavailable") };
|
||||
}
|
||||
|
||||
export async function fetchAuthStatus(): Promise<AuthStatus> {
|
||||
const obj = (await getJson("/api/auth/status")) as Partial<AuthStatus>;
|
||||
return { verified: obj?.verified === true, email: obj?.email ?? null };
|
||||
|
||||
Reference in New Issue
Block a user