mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-10 03:37:44 +02:00
[eric] approvals: trust patterns for action approvals + smarter sensitive-file gating
This commit is contained in:
@@ -20,9 +20,11 @@ from backend.apps.tools_lib.tools_lib import (
|
||||
_sanitize_server_name,
|
||||
derive_mcp_config,
|
||||
load_builtin_permissions,
|
||||
load_trusted_sensitive_paths,
|
||||
refresh_airtable_token,
|
||||
refresh_google_token,
|
||||
refresh_hubspot_token,
|
||||
save_trusted_sensitive_paths,
|
||||
)
|
||||
from backend.config.paths import SESSIONS_DIR
|
||||
from backend.apps.service.client import sync as _sync
|
||||
@@ -1124,35 +1126,62 @@ class AgentManager:
|
||||
def _default_for(tool_name: str) -> str:
|
||||
return _DEFAULTS.get(tool_name, "always_allow")
|
||||
|
||||
# Path patterns that flip Write/Edit/NotebookEdit from always_allow
|
||||
# to ask regardless of the user's base permission. Targets the
|
||||
# narrow set of files a prompt-injected agent would use to exfil
|
||||
# or persist (SSH keys, shell rc files, env files, cloud creds,
|
||||
# system dirs). Normal in-project / in-workspace / in-Downloads
|
||||
# edits never match — keeping the prompt-fatigue surface tiny.
|
||||
# Defense-in-depth path gate: flips Write/Edit/NotebookEdit from
|
||||
# always_allow to ask only for paths where a prompt-injected write
|
||||
# would grant the attacker persistence or credential exfil that the
|
||||
# user can't easily undo. Excludes routine dev artifacts (.env files,
|
||||
# generic app-support dirs); those triggered approvals on every
|
||||
# webapp build and contradicted the user's UI setting without
|
||||
# blocking any real attacker (a project .env doesn't auto-execute
|
||||
# and doesn't grant persistence; the threat is SSH auth keys, shell
|
||||
# rc files that auto-source on login, publish tokens, keychains,
|
||||
# and system dirs).
|
||||
import fnmatch as _fnmatch
|
||||
|
||||
_SENSITIVE_PATH_PATTERNS = (
|
||||
"*/.ssh", "*/.ssh/*",
|
||||
"*/.aws/*", "*/.config/gcloud/*", "*/.kube/*",
|
||||
"*/.gnupg/*", "*/.docker/config*",
|
||||
"*/.zshrc", "*/.bashrc", "*/.bash_profile",
|
||||
"*/.profile", "*/.zprofile", "*/.zshenv",
|
||||
"*/.gitconfig", "*/.npmrc", "*/.pypirc", "*/.netrc",
|
||||
"*/.env", "*/.env.*", "*.env",
|
||||
"*/Library/Application Support/*", # macOS credential stores
|
||||
"*/Library/Keychains/*",
|
||||
"/etc/*", "/private/etc/*", "/System/*",
|
||||
"/usr/local/etc/*",
|
||||
)
|
||||
# Each entry: pattern -> (short label, plain-English risk).
|
||||
# Multiple patterns can describe the same folder, but the user-
|
||||
# facing label/risk is what we show in the approval card, so it
|
||||
# has to read clearly to a non-developer who has no idea what
|
||||
# `~/.ssh/authorized_keys` is.
|
||||
_SENSITIVE_PATH_INFO: dict[str, tuple[str, str]] = {
|
||||
"*/.ssh": ("SSH folder (~/.ssh)", "Controls who can log in to your computer remotely."),
|
||||
"*/.ssh/*": ("SSH folder (~/.ssh)", "Controls who can log in to your computer remotely."),
|
||||
"*/.aws/*": ("AWS credentials (~/.aws)", "Cloud account access keys; can spend money and read your data."),
|
||||
"*/.config/gcloud/*": ("Google Cloud credentials", "Cloud account access; can spend money and read your data."),
|
||||
"*/.kube/*": ("Kubernetes config (~/.kube)", "Admin access to your Kubernetes clusters."),
|
||||
"*/.gnupg/*": ("GPG encryption keys", "Your private encryption keys; lets attackers decrypt your data or sign as you."),
|
||||
"*/.docker/config*": ("Docker credentials", "Login tokens for container registries."),
|
||||
"*/.zshrc": ("Shell startup file (.zshrc)", "Runs automatically every time you open a terminal."),
|
||||
"*/.bashrc": ("Shell startup file (.bashrc)", "Runs automatically every time you open a terminal."),
|
||||
"*/.bash_profile": ("Shell startup file (.bash_profile)", "Runs automatically every time you log in."),
|
||||
"*/.profile": ("Shell startup file (.profile)", "Runs automatically every time you log in."),
|
||||
"*/.zprofile": ("Shell startup file (.zprofile)", "Runs automatically every time you log in."),
|
||||
"*/.zshenv": ("Shell environment file (.zshenv)", "Runs automatically for every shell, including non-interactive ones."),
|
||||
"*/.gitconfig": ("Global Git config", "Affects every Git command you run; can hijack commits."),
|
||||
"*/.npmrc": ("npm auth file (~/.npmrc)", "Lets you publish npm packages; a token here can publish malicious packages as you."),
|
||||
"*/.pypirc": ("PyPI auth file (~/.pypirc)", "Lets you publish Python packages; a token here can publish malicious packages as you."),
|
||||
"*/.netrc": ("Stored login info (~/.netrc)", "Saved passwords for various services."),
|
||||
"*/Library/Keychains/*": ("macOS Keychain", "Where macOS stores all your saved passwords."),
|
||||
"/etc/*": ("System config (/etc)", "Affects the whole computer, not just your account."),
|
||||
"/private/etc/*": ("System config (/etc)", "Affects the whole computer, not just your account."),
|
||||
"/System/*": ("macOS system folder", "Affects the whole computer; should almost never be modified."),
|
||||
"/usr/local/etc/*": ("System config (/usr/local/etc)", "Affects the whole computer, not just your account."),
|
||||
}
|
||||
_SENSITIVE_PATH_PATTERNS = tuple(_SENSITIVE_PATH_INFO.keys())
|
||||
|
||||
def _is_sensitive_write_path(file_path: str) -> bool:
|
||||
def _match_sensitive_pattern(file_path: str) -> str | None:
|
||||
"""Return the matched sensitive pattern, or None if the path isn't
|
||||
sensitive OR if the user has previously trusted that pattern. The
|
||||
trusted list is reloaded on every call so a trust decision made
|
||||
during one approval takes effect for any later prompt fired in the
|
||||
same turn (no in-process cache to invalidate).
|
||||
"""
|
||||
if not file_path or not isinstance(file_path, str):
|
||||
return False
|
||||
return None
|
||||
try:
|
||||
norm = os.path.normpath(os.path.expanduser(file_path))
|
||||
except Exception:
|
||||
return False
|
||||
return None
|
||||
# Normalize to forward slashes so the patterns match on Windows
|
||||
# too — `os.path.normpath` produces backslashes on Windows
|
||||
# (`C:\Users\eric\.ssh\authorized_keys`), and fnmatch treats
|
||||
@@ -1162,13 +1191,109 @@ class AgentManager:
|
||||
# would go through unchallenged.
|
||||
if os.sep != '/':
|
||||
norm = norm.replace(os.sep, '/')
|
||||
trusted = set(load_trusted_sensitive_paths())
|
||||
for pat in _SENSITIVE_PATH_PATTERNS:
|
||||
if pat in trusted:
|
||||
continue
|
||||
if _fnmatch.fnmatch(norm, pat):
|
||||
return True
|
||||
return False
|
||||
return pat
|
||||
return None
|
||||
|
||||
_PATH_GATED_TOOLS = ("Write", "Edit", "NotebookEdit")
|
||||
|
||||
# OS-level scheduling across macOS/Linux/Windows. Agent must
|
||||
# not install cron entries, launchd plists, Windows scheduled
|
||||
# tasks, or PowerShell ScheduledTask cmdlets behind the user's
|
||||
# back. Word-bounded so we don't flag stray strings in echo etc.
|
||||
import re as _re_sched
|
||||
_OS_SCHED_RE = _re_sched.compile(
|
||||
r"\b("
|
||||
r"crontab|launchctl|launchd|schtasks|systemd-run|"
|
||||
r"systemctl\s+--user.*timer|at\s+\d|at\s+now|at\s+-f|"
|
||||
# Windows PowerShell scheduled-task cmdlets:
|
||||
r"Register-ScheduledTask|New-ScheduledTask|Set-ScheduledTask|"
|
||||
r"Register-ScheduledJob|New-ScheduledJob"
|
||||
r")\b",
|
||||
_re_sched.IGNORECASE,
|
||||
)
|
||||
|
||||
def _looks_like_os_scheduling(tool_input) -> bool:
|
||||
if not isinstance(tool_input, dict):
|
||||
return False
|
||||
cmd = str(tool_input.get("command") or "")
|
||||
if not cmd:
|
||||
return False
|
||||
return bool(_OS_SCHED_RE.search(cmd))
|
||||
|
||||
# Catastrophic-path Bash gate. Bash is intentionally NOT in
|
||||
# _PATH_GATED_TOOLS because gating every `echo ... > /tmp/foo` would
|
||||
# interrupt routine work; but a single redirected write to one of
|
||||
# these paths can grant persistent attacker access (SSH keys,
|
||||
# sudoers, Keychain) or break the OS in ways the user can't
|
||||
# recover from. Scope is intentionally tighter than the Write/Edit
|
||||
# list because Bash is the agent's hot path and we'd rather miss
|
||||
# a borderline case than gate routine commands. The trust list is
|
||||
# shared with Write/Edit so a single "Always allow" decision in
|
||||
# the modal covers both surfaces.
|
||||
_BASH_CATASTROPHIC_INFO: dict[str, tuple[str, str]] = {
|
||||
"*/.ssh/*": ("SSH folder (~/.ssh)", "Controls who can log in to your computer remotely."),
|
||||
"/etc/sudoers": ("Sudo permissions (/etc/sudoers)", "Controls which commands can run with admin privileges."),
|
||||
"/etc/sudoers.d/*": ("Sudo permissions (/etc/sudoers.d)", "Controls which commands can run with admin privileges."),
|
||||
"/etc/passwd": ("System user list (/etc/passwd)", "Defines every user account on this computer."),
|
||||
"/etc/shadow": ("System password file (/etc/shadow)", "Stores password hashes for every user account."),
|
||||
"*/Library/Keychains/*": ("macOS Keychain", "Where macOS stores all your saved passwords."),
|
||||
"/System/*": ("macOS system folder", "Affects the whole computer; should almost never be modified."),
|
||||
}
|
||||
_BASH_CATASTROPHIC_PATTERNS = tuple(_BASH_CATASTROPHIC_INFO.keys())
|
||||
|
||||
# Token-extraction regex: pulls quoted strings AND bare path-like
|
||||
# tokens out of the Bash command so we can match against the
|
||||
# catastrophic list. Intentionally loose: false positives just
|
||||
# mean an extra approval prompt, never a missed gate.
|
||||
_BASH_PATH_TOKEN_RE = _re_sched.compile(
|
||||
r"""(?P<quoted>"[^"]+"|'[^']+')|(?P<bare>[~/.][\w./~\-]*)"""
|
||||
)
|
||||
|
||||
# Write operators we care about. Presence alone is not enough;
|
||||
# we also need a sensitive target in the same command. Includes
|
||||
# both shell redirection (`>`, `>>`, `tee`) and tools that take
|
||||
# an explicit destination flag (`cp`, `mv`, `dd of=`, `sed -i`,
|
||||
# `install`, `chmod`, `chown`, `rm`).
|
||||
_BASH_WRITE_OP_RE = _re_sched.compile(
|
||||
r"(?:>>?|\btee\b|\bsed\s+-i\b|\bcp\b|\bmv\b|\bdd\b[^|]*\bof=|\binstall\b|\bchmod\b|\bchown\b|\brm\b|\btouch\b|\bmkdir\b|\bln\b)",
|
||||
_re_sched.IGNORECASE,
|
||||
)
|
||||
|
||||
def _match_bash_catastrophic_pattern(command: str) -> str | None:
|
||||
"""Return the matched catastrophic-path pattern for a Bash
|
||||
command, or None if the command isn't writing to one (or the
|
||||
user has trusted that pattern). Same trust-list as
|
||||
_match_sensitive_pattern so toggling once covers both.
|
||||
"""
|
||||
if not command or not isinstance(command, str):
|
||||
return None
|
||||
if not _BASH_WRITE_OP_RE.search(command):
|
||||
return None
|
||||
trusted = set(load_trusted_sensitive_paths())
|
||||
for raw_match in _BASH_PATH_TOKEN_RE.finditer(command):
|
||||
tok = (raw_match.group("quoted") or raw_match.group("bare") or "")
|
||||
if tok and tok[0] in ("'", '"'):
|
||||
tok = tok[1:-1]
|
||||
if not tok:
|
||||
continue
|
||||
try:
|
||||
norm = os.path.normpath(os.path.expanduser(tok))
|
||||
except Exception:
|
||||
continue
|
||||
if os.sep != '/':
|
||||
norm = norm.replace(os.sep, '/')
|
||||
for pat in _BASH_CATASTROPHIC_PATTERNS:
|
||||
if pat in trusted:
|
||||
continue
|
||||
if _fnmatch.fnmatch(norm, pat):
|
||||
return pat
|
||||
return None
|
||||
|
||||
def _extract_target_path(tool_name: str, tool_input) -> str:
|
||||
if not isinstance(tool_input, dict):
|
||||
return ""
|
||||
@@ -1176,16 +1301,35 @@ class AgentManager:
|
||||
return str(tool_input.get("notebook_path") or "")
|
||||
return str(tool_input.get("file_path") or "")
|
||||
|
||||
def _maybe_override_policy(policy: str, tool_name: str, tool_input) -> str:
|
||||
"""Flip a permissive policy to 'ask' when the target path is
|
||||
sensitive. Defense in depth: even if the user has Write set to
|
||||
always_allow for productivity, a prompt-injected agent writing
|
||||
to ~/.ssh/authorized_keys or ~/.zshrc gets surfaced for review."""
|
||||
def _maybe_override_policy(policy: str, tool_name: str, tool_input) -> tuple[str, str | None]:
|
||||
"""Returns (effective_policy, matched_sensitive_pattern).
|
||||
|
||||
Flips a permissive policy to 'ask' when the target path is
|
||||
sensitive (and not in the user's trusted allowlist). Defense
|
||||
in depth: even if the user has Write set to always_allow for
|
||||
productivity, a prompt-injected agent writing to
|
||||
~/.ssh/authorized_keys or ~/.zshrc gets surfaced for review;
|
||||
but once the user opts into "always allow files like this"
|
||||
for a given pattern, future writes to that pattern pass
|
||||
through silently.
|
||||
|
||||
Also: Bash invocations that look like OS-level scheduling
|
||||
(crontab, launchctl, schtasks, at, systemd-run --on-calendar)
|
||||
are flipped to 'ask' regardless of permission policy; we
|
||||
don't want the agent silently installing cron entries.
|
||||
"""
|
||||
if tool_name == "Bash" and _looks_like_os_scheduling(tool_input):
|
||||
return "ask", None
|
||||
if tool_name == "Bash" and isinstance(tool_input, dict):
|
||||
bash_match = _match_bash_catastrophic_pattern(str(tool_input.get("command") or ""))
|
||||
if bash_match:
|
||||
return "ask", bash_match
|
||||
if policy != "always_allow" or tool_name not in _PATH_GATED_TOOLS:
|
||||
return policy
|
||||
if _is_sensitive_write_path(_extract_target_path(tool_name, tool_input)):
|
||||
return "ask"
|
||||
return policy
|
||||
return policy, None
|
||||
matched = _match_sensitive_pattern(_extract_target_path(tool_name, tool_input))
|
||||
if matched:
|
||||
return "ask", matched
|
||||
return policy, None
|
||||
|
||||
def _get_effective_policy(tool_name: str) -> str:
|
||||
"""Return 'always_allow', 'deny', or 'ask' for any tool."""
|
||||
@@ -1212,15 +1356,28 @@ class AgentManager:
|
||||
return t.tool_permissions.get(mcp_tool_name, "ask")
|
||||
return _default_for(tool_name)
|
||||
|
||||
async def _request_user_approval(tool_name: str, tool_input) -> dict:
|
||||
async def _request_user_approval(
|
||||
tool_name: str,
|
||||
tool_input,
|
||||
sensitive_pattern: str | None = None,
|
||||
) -> dict:
|
||||
"""Send an approval request via WebSocket and wait for the user's decision."""
|
||||
safe_input = tool_input if isinstance(tool_input, dict) else {}
|
||||
request_id = uuid4().hex
|
||||
label, why = (None, None)
|
||||
if sensitive_pattern:
|
||||
if sensitive_pattern in _SENSITIVE_PATH_INFO:
|
||||
label, why = _SENSITIVE_PATH_INFO[sensitive_pattern]
|
||||
elif sensitive_pattern in _BASH_CATASTROPHIC_INFO:
|
||||
label, why = _BASH_CATASTROPHIC_INFO[sensitive_pattern]
|
||||
approval_req = ApprovalRequest(
|
||||
id=request_id,
|
||||
session_id=session_id,
|
||||
tool_name=tool_name,
|
||||
tool_input=safe_input,
|
||||
sensitive_pattern=sensitive_pattern,
|
||||
sensitive_label=label,
|
||||
sensitive_why=why,
|
||||
)
|
||||
session.pending_approvals.append(approval_req)
|
||||
session.status = "waiting_approval"
|
||||
@@ -1232,8 +1389,27 @@ class AgentManager:
|
||||
})
|
||||
|
||||
decision = await ws_manager.send_approval_request(
|
||||
session_id, request_id, tool_name, safe_input
|
||||
session_id, request_id, tool_name, safe_input,
|
||||
sensitive_pattern=sensitive_pattern,
|
||||
sensitive_label=label,
|
||||
sensitive_why=why,
|
||||
)
|
||||
# If the user opted into trusting this pattern, persist now so
|
||||
# any subsequent prompt against the same pattern (e.g. the
|
||||
# PreToolUse hook re-evaluating after can_use_tool, or a later
|
||||
# Write in the same session) skips the modal silently.
|
||||
if (
|
||||
decision.get("behavior") == "allow"
|
||||
and decision.get("trust_pattern")
|
||||
and sensitive_pattern
|
||||
):
|
||||
try:
|
||||
existing = load_trusted_sensitive_paths()
|
||||
if sensitive_pattern not in existing:
|
||||
existing.append(sensitive_pattern)
|
||||
save_trusted_sensitive_paths(existing)
|
||||
except Exception:
|
||||
logger.exception("Failed to persist trusted sensitive path")
|
||||
|
||||
approval_latency_ms = int((datetime.now() - approval_req.created_at).total_seconds() * 1000)
|
||||
try:
|
||||
@@ -1258,8 +1434,9 @@ class AgentManager:
|
||||
return decision
|
||||
|
||||
async def can_use_tool(tool_name, input_data, context):
|
||||
sensitive_pattern: str | None = None
|
||||
if tool_name != "AskUserQuestion":
|
||||
policy = _maybe_override_policy(
|
||||
policy, sensitive_pattern = _maybe_override_policy(
|
||||
_get_effective_policy(tool_name), tool_name, input_data
|
||||
)
|
||||
if policy == "always_allow":
|
||||
@@ -1267,7 +1444,7 @@ class AgentManager:
|
||||
if policy == "deny":
|
||||
return PermissionResultDeny(message="Tool denied by permission policy")
|
||||
|
||||
decision = await _request_user_approval(tool_name, input_data)
|
||||
decision = await _request_user_approval(tool_name, input_data, sensitive_pattern=sensitive_pattern)
|
||||
if decision.get("behavior") == "allow":
|
||||
return PermissionResultAllow(
|
||||
updated_input=decision.get("updated_input", input_data)
|
||||
@@ -1284,7 +1461,7 @@ class AgentManager:
|
||||
|
||||
if tool_name and tool_name != "AskUserQuestion":
|
||||
tool_input = input_data.get("tool_input", {})
|
||||
policy = _maybe_override_policy(
|
||||
policy, sensitive_pattern = _maybe_override_policy(
|
||||
_get_effective_policy(tool_name), tool_name, tool_input
|
||||
)
|
||||
|
||||
@@ -1298,7 +1475,7 @@ class AgentManager:
|
||||
}
|
||||
|
||||
if policy == "ask":
|
||||
decision = await _request_user_approval(tool_name, tool_input)
|
||||
decision = await _request_user_approval(tool_name, tool_input, sensitive_pattern=sensitive_pattern)
|
||||
|
||||
if decision.get("behavior") == "allow":
|
||||
if tool_use_id:
|
||||
|
||||
@@ -111,6 +111,7 @@ async def handle_approval(response: ApprovalResponse):
|
||||
"behavior": response.behavior,
|
||||
"message": response.message,
|
||||
"updated_input": response.updated_input,
|
||||
"trust_pattern": response.trust_pattern,
|
||||
})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -20,12 +20,28 @@ class ApprovalRequest(BaseModel):
|
||||
tool_name: str
|
||||
tool_input: dict[str, Any]
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
# Set when this approval was triggered by the sensitive-path override
|
||||
# rather than the user's normal "ask" policy. Three correlated fields:
|
||||
# - sensitive_pattern: the fnmatch pattern (canonical id; what we
|
||||
# persist into the trusted allowlist if the user opts in).
|
||||
# - sensitive_label: short human label (e.g. "SSH folder (~/.ssh)").
|
||||
# - sensitive_why: plain-English risk explanation; lets the modal
|
||||
# justify itself to a non-developer.
|
||||
# All three None for ordinary "ask" approvals.
|
||||
sensitive_pattern: Optional[str] = None
|
||||
sensitive_label: Optional[str] = None
|
||||
sensitive_why: Optional[str] = None
|
||||
|
||||
class ApprovalResponse(BaseModel):
|
||||
request_id: str
|
||||
behavior: Literal["allow", "deny"]
|
||||
message: Optional[str] = None
|
||||
updated_input: Optional[dict[str, Any]] = None
|
||||
# When the user checked "Always allow files like this" on a sensitive-
|
||||
# path approval, the backend persists the matched fnmatch pattern
|
||||
# (from ApprovalRequest.sensitive_pattern) to disk so future writes
|
||||
# against the same pattern skip the modal.
|
||||
trust_pattern: bool = False
|
||||
|
||||
class Message(BaseModel):
|
||||
id: str = Field(default_factory=lambda: uuid4().hex)
|
||||
|
||||
@@ -178,6 +178,9 @@ class ConnectionManager:
|
||||
async def send_approval_request(
|
||||
self, session_id: str, request_id: str, tool_name: str, tool_input: dict,
|
||||
timeout: float = 600.0,
|
||||
sensitive_pattern: str | None = None,
|
||||
sensitive_label: str | None = None,
|
||||
sensitive_why: str | None = None,
|
||||
) -> dict:
|
||||
"""Send an approval request and wait for the user's response.
|
||||
|
||||
@@ -188,11 +191,16 @@ class ConnectionManager:
|
||||
future = asyncio.get_event_loop().create_future()
|
||||
self.pending_futures[request_id] = future
|
||||
|
||||
await self.send_to_session(session_id, "agent:approval_request", {
|
||||
payload: dict = {
|
||||
"request_id": request_id,
|
||||
"tool_name": tool_name,
|
||||
"tool_input": tool_input,
|
||||
})
|
||||
}
|
||||
if sensitive_pattern:
|
||||
payload["sensitive_pattern"] = sensitive_pattern
|
||||
payload["sensitive_label"] = sensitive_label
|
||||
payload["sensitive_why"] = sensitive_why
|
||||
await self.send_to_session(session_id, "agent:approval_request", payload)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(future, timeout=timeout)
|
||||
|
||||
@@ -179,7 +179,20 @@ def _decode_data_param(d: str) -> tuple[str, str]:
|
||||
async def outputs_lifespan():
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
os.makedirs(WORKSPACE_DIR, exist_ok=True)
|
||||
yield
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Reap every per-app subprocess. Without this each `bash run.sh`
|
||||
# (and its vite/uvicorn descendants) reparents to PID 1 when the
|
||||
# main backend dies, leaving ghost listeners on the .env-pinned
|
||||
# ports that block the next OpenSwarm launch's reload preview.
|
||||
try:
|
||||
from backend.apps.outputs.runtime import manager as runtime_manager
|
||||
killed = await runtime_manager.stop_all()
|
||||
if killed:
|
||||
logger.info("outputs lifespan: reaped %d workspace runtimes on shutdown", killed)
|
||||
except Exception:
|
||||
logger.exception("outputs lifespan: stop_all failed")
|
||||
|
||||
|
||||
outputs = SubApp("outputs", outputs_lifespan)
|
||||
@@ -580,6 +593,17 @@ async def runtime_get_status(workspace_id: str):
|
||||
return _runtime_status_payload(workspace_id)
|
||||
|
||||
|
||||
@outputs.router.post("/shutdown-all")
|
||||
async def runtime_shutdown_all():
|
||||
"""Reap every workspace subprocess. Electron POSTs this during
|
||||
will-quit so app subprocesses die BEFORE the main backend gets
|
||||
SIGTERM'd; without it `bash run.sh` + its vite/uvicorn descendants
|
||||
reparent to PID 1 and squat on .env-pinned ports forever."""
|
||||
from backend.apps.outputs.runtime import manager as runtime_manager
|
||||
killed = await runtime_manager.stop_all()
|
||||
return {"ok": True, "killed": killed}
|
||||
|
||||
|
||||
@outputs.router.put("/workspace/{workspace_id}/file/{filepath:path}")
|
||||
async def write_workspace_file(workspace_id: str, filepath: str, body: dict):
|
||||
"""Write (create/overwrite) a single file in a workspace."""
|
||||
|
||||
@@ -181,6 +181,94 @@ def _find_free_port() -> int:
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _kill_descendant_tree(pid: int, sig_name: str = "TERM") -> None:
|
||||
"""Recursively signal every descendant of `pid`, leaves-first. The
|
||||
webapp template's run.sh installs `trap cleanup EXIT` (no TERM), so a
|
||||
plain SIGTERM to the bash wrapper exits bash silently and leaves
|
||||
vite/uvicorn grandchildren reparented to PID 1, squatting on the
|
||||
workspace's ports. Walking the tree ourselves bypasses the template's
|
||||
signal-handling habits entirely. POSIX uses `pgrep -P` to enumerate
|
||||
direct children; Windows is covered by `taskkill /T /F` (job-object
|
||||
walk). All failures are swallowed; missing PIDs mean the process
|
||||
already exited, which is the desired state anyway."""
|
||||
if os.name == "nt":
|
||||
try:
|
||||
subprocess.run(
|
||||
["taskkill", "/PID", str(pid), "/T", "/F"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=5,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["pgrep", "-P", str(pid)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
children = [int(p) for p in out.stdout.split() if p.strip().isdigit()]
|
||||
except Exception:
|
||||
children = []
|
||||
for child in children:
|
||||
_kill_descendant_tree(child, sig_name)
|
||||
sig = getattr(signal, f"SIG{sig_name}", signal.SIGTERM)
|
||||
for child in children:
|
||||
try:
|
||||
os.kill(child, sig)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def _is_port_free(port: int) -> bool:
|
||||
"""True if nothing currently holds a TCP listener on 127.0.0.1:port.
|
||||
Cheap kernel-probe; resolves on bind success. Used as the cross-session
|
||||
safety net: if a prior OpenSwarm run left a ghost subprocess holding
|
||||
the .env-persisted FRONTEND_PORT, we detect it here and reallocate
|
||||
rather than handing run.sh a port that will EADDRINUSE."""
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", port))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _write_env_value(env_path: str, key: str, value: str) -> None:
|
||||
"""Update KEY=VALUE in an existing `.env`, preserving every other
|
||||
line. Creates the file if missing. Used when a persisted port collides
|
||||
with a ghost from a prior session and we have to reallocate before
|
||||
spawning run.sh."""
|
||||
lines: list[str] = []
|
||||
found = False
|
||||
if os.path.exists(env_path):
|
||||
try:
|
||||
with open(env_path, encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
except Exception:
|
||||
lines = []
|
||||
for i, raw in enumerate(lines):
|
||||
stripped = raw.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
k = stripped.split("=", 1)[0].strip()
|
||||
if k == key:
|
||||
lines[i] = f"{key}={value}\n"
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
if lines and not lines[-1].endswith("\n"):
|
||||
lines[-1] = lines[-1] + "\n"
|
||||
lines.append(f"{key}={value}\n")
|
||||
try:
|
||||
with open(env_path, "w", encoding="utf-8") as f:
|
||||
f.writelines(lines)
|
||||
except Exception:
|
||||
logger.exception("failed writing %s=%s to %s", key, value, env_path)
|
||||
|
||||
|
||||
def _is_new_mode(workspace_path: str) -> bool:
|
||||
"""A workspace is "new-mode" (webapp-template scaffold) if it has a
|
||||
`run.sh` at its root. Old-mode workspaces are flat `index.html`-only
|
||||
@@ -358,12 +446,25 @@ class AppRuntime:
|
||||
fp_raw = _read_env_value(env_path, "FRONTEND_PORT")
|
||||
bp_raw = _read_env_value(env_path, "BACKEND_PORT")
|
||||
# FRONTEND_PORT is allocated by seed_workspace; should always be
|
||||
# a number. If missing, log + fall back to a fresh allocation —
|
||||
# rare edge case (workspace seeded by an older OpenSwarm).
|
||||
# a number. If missing, fall back to a fresh allocation (rare
|
||||
# edge case: workspace seeded by an older OpenSwarm).
|
||||
try:
|
||||
self.frontend_port = int(fp_raw) if fp_raw else _find_free_port()
|
||||
except ValueError:
|
||||
self.frontend_port = _find_free_port()
|
||||
# Port-collision safety net: if a ghost subprocess from a prior
|
||||
# OpenSwarm run is still bound to the persisted port (force-quit,
|
||||
# crash, OS killed the parent before stop_all could reap), Vite
|
||||
# would EADDRINUSE silently. Re-probe and reallocate, then rewrite
|
||||
# .env so the bash run.sh subprocess reads the new port.
|
||||
if self.frontend_port and not _is_port_free(self.frontend_port):
|
||||
new_port = _find_free_port()
|
||||
self._broadcast(LogLine(
|
||||
"runtime",
|
||||
f"[runtime] persisted FRONTEND_PORT {self.frontend_port} is in use; reallocating to {new_port}",
|
||||
))
|
||||
self.frontend_port = new_port
|
||||
_write_env_value(env_path, "FRONTEND_PORT", str(new_port))
|
||||
# BACKEND_PORT may be the literal string "NONE" (frontend-only
|
||||
# app — the common case) or a number once `backend_init.sh` has
|
||||
# run. Only populate self.port when there's a real backend.
|
||||
@@ -372,6 +473,16 @@ class AppRuntime:
|
||||
self.port = int(bp_raw)
|
||||
except ValueError:
|
||||
self.port = None
|
||||
# Same collision check for the backend port; a leaked uvicorn
|
||||
# from a prior session would otherwise block the new spawn.
|
||||
if self.port and not _is_port_free(self.port):
|
||||
new_port = _find_free_port()
|
||||
self._broadcast(LogLine(
|
||||
"runtime",
|
||||
f"[runtime] persisted BACKEND_PORT {self.port} is in use; reallocating to {new_port}",
|
||||
))
|
||||
self.port = new_port
|
||||
_write_env_value(env_path, "BACKEND_PORT", str(new_port))
|
||||
else:
|
||||
self.port = None
|
||||
|
||||
@@ -542,10 +653,16 @@ class AppRuntime:
|
||||
self._frontend_ready_task.cancel()
|
||||
return
|
||||
try:
|
||||
# Walk the descendant tree first so vite/uvicorn grandchildren
|
||||
# die before bash exits and orphans them to PID 1. The webapp
|
||||
# template's run.sh only traps EXIT, not TERM, so a flat
|
||||
# SIGTERM to bash kills bash silently and leaves vite alive.
|
||||
_kill_descendant_tree(self.process.pid, "TERM")
|
||||
self.process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(self.process.wait(), timeout=_TERMINATE_GRACE_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
_kill_descendant_tree(self.process.pid, "KILL")
|
||||
self.process.kill()
|
||||
await self.process.wait()
|
||||
except ProcessLookupError:
|
||||
@@ -767,5 +884,33 @@ class AppRuntimeManager:
|
||||
await rt.restart()
|
||||
return rt
|
||||
|
||||
async def stop_all(self) -> int:
|
||||
"""Terminate every active + idle workspace subprocess. Called on
|
||||
FastAPI lifespan shutdown AND from Electron's pre-quit POST. Without
|
||||
this, each `bash run.sh` (and its vite/uvicorn descendants) reparents
|
||||
to PID 1 when the main backend dies, leaving ghost listeners on the
|
||||
persisted FRONTEND_PORT/BACKEND_PORT that block the NEXT OpenSwarm
|
||||
launch's app reload. Wakes any SIGSTOP'd idle entries before reaping
|
||||
so they can run their own shutdown. Parallel via gather; with the
|
||||
per-runtime 3s SIGTERM grace, worst case is one ~3s wait rather than
|
||||
N*3s. Idempotent; safe to invoke from multiple shutdown paths."""
|
||||
async with self._lock:
|
||||
victims: list[AppRuntime] = []
|
||||
for rt in list(self.runtimes.values()):
|
||||
victims.append(rt)
|
||||
for rt in list(self._idle_lru.values()):
|
||||
_resume_process_tree(rt.process)
|
||||
victims.append(rt)
|
||||
self.runtimes.clear()
|
||||
self._idle_lru.clear()
|
||||
self._attached.clear()
|
||||
if not victims:
|
||||
return 0
|
||||
await asyncio.gather(
|
||||
*(rt.stop() for rt in victims),
|
||||
return_exceptions=True,
|
||||
)
|
||||
return len(victims)
|
||||
|
||||
|
||||
manager = AppRuntimeManager()
|
||||
|
||||
@@ -26,7 +26,7 @@ OPENSWARM_OAUTH_BASE_URL = os.environ.get(
|
||||
"OPENSWARM_OAUTH_BASE_URL", "https://api.openswarm.com"
|
||||
).rstrip("/")
|
||||
|
||||
from backend.config.paths import BACKEND_DIR, DATA_ROOT, TOOLS_DIR as DATA_DIR, BUILTIN_PERMISSIONS_PATH as BUILTIN_PERMS_PATH
|
||||
from backend.config.paths import BACKEND_DIR, DATA_ROOT, TOOLS_DIR as DATA_DIR, BUILTIN_PERMISSIONS_PATH as BUILTIN_PERMS_PATH, TRUSTED_SENSITIVE_PATHS_PATH
|
||||
|
||||
load_dotenv(os.path.join(BACKEND_DIR, ".env"))
|
||||
if os.environ.get("OPENSWARM_PACKAGED") == "1":
|
||||
@@ -105,11 +105,51 @@ def save_builtin_permissions(perms: dict[str, str]):
|
||||
json.dump(perms, f, indent=2)
|
||||
|
||||
|
||||
def load_trusted_sensitive_paths() -> list[str]:
|
||||
if not os.path.exists(TRUSTED_SENSITIVE_PATHS_PATH):
|
||||
return []
|
||||
try:
|
||||
with open(TRUSTED_SENSITIVE_PATHS_PATH) as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return []
|
||||
raw = data.get("patterns") if isinstance(data, dict) else None
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [p for p in raw if isinstance(p, str) and p]
|
||||
|
||||
|
||||
def save_trusted_sensitive_paths(patterns: list[str]):
|
||||
os.makedirs(os.path.dirname(TRUSTED_SENSITIVE_PATHS_PATH), exist_ok=True)
|
||||
seen: list[str] = []
|
||||
for p in patterns:
|
||||
if isinstance(p, str) and p and p not in seen:
|
||||
seen.append(p)
|
||||
with open(TRUSTED_SENSITIVE_PATHS_PATH, "w") as f:
|
||||
json.dump({"patterns": seen}, f, indent=2)
|
||||
|
||||
|
||||
@tools_lib.router.get("/builtin/permissions")
|
||||
async def get_builtin_permissions():
|
||||
return {"permissions": load_builtin_permissions()}
|
||||
|
||||
|
||||
@tools_lib.router.get("/trusted-sensitive-paths")
|
||||
async def get_trusted_sensitive_paths():
|
||||
"""Patterns the user has opted into always-allow for sensitive-path writes."""
|
||||
return {"patterns": load_trusted_sensitive_paths()}
|
||||
|
||||
|
||||
@tools_lib.router.put("/trusted-sensitive-paths")
|
||||
async def replace_trusted_sensitive_paths(body: dict):
|
||||
"""Replace the full list; Settings page uses this to revoke entries."""
|
||||
incoming = body.get("patterns") or []
|
||||
if not isinstance(incoming, list):
|
||||
return {"patterns": load_trusted_sensitive_paths()}
|
||||
save_trusted_sensitive_paths([p for p in incoming if isinstance(p, str) and p])
|
||||
return {"patterns": load_trusted_sensitive_paths()}
|
||||
|
||||
|
||||
@tools_lib.router.put("/builtin/permissions")
|
||||
async def update_builtin_permissions(body: dict):
|
||||
valid_tools = {t.name for t in BUILTIN_TOOLS}
|
||||
|
||||
@@ -34,6 +34,7 @@ OUTPUTS_WORKSPACE_DIR = os.path.join(DATA_ROOT, "outputs_workspace")
|
||||
SKILLS_WORKSPACE_DIR = os.path.join(DATA_ROOT, "skills_workspace")
|
||||
DASHBOARD_LAYOUT_DIR = os.path.join(DATA_ROOT, "dashboard_layout")
|
||||
BUILTIN_PERMISSIONS_PATH = os.path.join(DATA_ROOT, "builtin_permissions.json")
|
||||
TRUSTED_SENSITIVE_PATHS_PATH = os.path.join(DATA_ROOT, "trusted_sensitive_paths.json")
|
||||
|
||||
# Per-install auth token for the localhost WS + HTTP API. Regenerated
|
||||
# every backend start. Only code running as the current OS user (Electron
|
||||
|
||||
+9
-6
@@ -223,6 +223,7 @@ async def websocket_session(websocket: WebSocket, session_id: str):
|
||||
"behavior": payload.get("behavior", "deny"),
|
||||
"message": payload.get("message"),
|
||||
"updated_input": payload.get("updated_input"),
|
||||
"trust_pattern": bool(payload.get("trust_pattern")),
|
||||
})
|
||||
elif event == "agent:edit_message":
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
@@ -368,6 +369,7 @@ async def websocket_dashboard(websocket: WebSocket):
|
||||
"behavior": payload.get("behavior", "deny"),
|
||||
"message": payload.get("message"),
|
||||
"updated_input": payload.get("updated_input"),
|
||||
"trust_pattern": bool(payload.get("trust_pattern")),
|
||||
})
|
||||
elif event == "browser:result":
|
||||
ws_manager.resolve_browser_command(
|
||||
@@ -702,14 +704,10 @@ async def session_compact(session_id: str):
|
||||
|
||||
@app.post("/api/agents/sessions/{session_id}/clear")
|
||||
async def session_clear(session_id: str):
|
||||
"""Reset a session to a fresh sdk_session_id (Phase 2 /clear slash cmd).
|
||||
|
||||
Preserves session.messages (so the chat UI keeps the visible history)
|
||||
but clears the SDK-side conversation by minting a new sdk_session_id.
|
||||
Also drops active_mcps so the user starts fresh.
|
||||
"""
|
||||
"""Wipe the session's UI history AND its SDK convo state (/clear slash cmd, Reset history button)."""
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
from backend.apps.agents.ws_manager import ws_manager as _ws
|
||||
from backend.apps.agents.models import MessageBranch
|
||||
session = agent_manager.sessions.get(session_id)
|
||||
if not session:
|
||||
return JSONResponse({"error": "session not found"}, status_code=404)
|
||||
@@ -719,6 +717,11 @@ async def session_clear(session_id: str):
|
||||
session.tokens = {"input": 0, "output": 0}
|
||||
session.cost_usd = 0.0
|
||||
session.needs_fork = False
|
||||
session.messages = []
|
||||
session.pending_approvals = []
|
||||
session.branches = {"main": MessageBranch(id="main")}
|
||||
session.active_branch_id = "main"
|
||||
session.tool_group_meta = {}
|
||||
await _ws.send_to_session(session_id, "agent:status", {
|
||||
"session_id": session_id,
|
||||
"status": session.status,
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""End-to-end test for the per-workspace runtime cleanup + port collision fix.
|
||||
|
||||
What this proves:
|
||||
1. AppRuntimeManager.stop_all() reaps active runtimes.
|
||||
2. AppRuntimeManager.stop_all() reaps idle (LRU) runtimes too.
|
||||
3. AppRuntimeManager.stop_all() resumes SIGSTOP'd idle runtimes before reaping (otherwise the SIGTERM is queued and the process never dies).
|
||||
4. _is_port_free() correctly detects collisions.
|
||||
5. _write_env_value() updates a single key without clobbering siblings.
|
||||
6. _start_new_mode() rewrites .env's FRONTEND_PORT when the persisted port is in use, and the spawned child sees the rewritten value.
|
||||
7. Same collision-rewrite happens for BACKEND_PORT when it's not "NONE".
|
||||
|
||||
Run with: backend/.venv/bin/python backend/tests/test_outputs_runtime_cleanup.py
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
|
||||
|
||||
from backend.apps.outputs.runtime import (
|
||||
AppRuntime,
|
||||
AppRuntimeManager,
|
||||
_find_free_port,
|
||||
_is_port_free,
|
||||
_read_env_value,
|
||||
_write_env_value,
|
||||
)
|
||||
|
||||
|
||||
# --- Fixture: matches the production webapp_template run.sh signal habits ---
|
||||
# trap cleanup EXIT only, no TERM. Reproduces the actual bug: SIGTERM kills
|
||||
# bash silently, EXIT trap doesn't fire on uncaught signal, python child gets
|
||||
# reparented to launchd. _kill_descendant_tree must walk the tree to nuke it.
|
||||
FAKE_RUN_SH = """#!/bin/bash
|
||||
set -e
|
||||
if [ -f .env ]; then
|
||||
set -a; . ./.env; set +a
|
||||
fi
|
||||
echo "[fake-run] FRONTEND_PORT=${FRONTEND_PORT:-unset} pid=$$"
|
||||
python3 -c "
|
||||
import socket, time, os
|
||||
s = socket.socket()
|
||||
s.bind(('127.0.0.1', int(os.environ['FRONTEND_PORT'])))
|
||||
s.listen(1)
|
||||
print(f'[fake-run] bound on {os.environ[\\"FRONTEND_PORT\\"]}', flush=True)
|
||||
while True:
|
||||
time.sleep(1)
|
||||
" &
|
||||
PYTHON_PID=$!
|
||||
# Mirror the real template: EXIT trap only. bash's default SIGTERM handler
|
||||
# exits without running EXIT, so this MUST NOT keep our descendant alive
|
||||
# if our kill-tree walker works correctly.
|
||||
cleanup() { kill $PYTHON_PID 2>/dev/null; }
|
||||
trap cleanup EXIT
|
||||
wait $PYTHON_PID
|
||||
"""
|
||||
|
||||
|
||||
def _make_fake_workspace(tmp: str, frontend_port: int, backend_port: str = "NONE") -> str:
|
||||
ws = os.path.join(tmp, "ws")
|
||||
os.makedirs(ws)
|
||||
with open(os.path.join(ws, "run.sh"), "w") as f:
|
||||
f.write(FAKE_RUN_SH)
|
||||
os.chmod(os.path.join(ws, "run.sh"), 0o755)
|
||||
with open(os.path.join(ws, ".env"), "w") as f:
|
||||
f.write(f"# header comment\nSOMETHING_ELSE=untouched\nFRONTEND_PORT={frontend_port}\nBACKEND_PORT={backend_port}\nTRAILING=keep\n")
|
||||
return ws
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
# --- Test 1: helpers ---
|
||||
def test_is_port_free():
|
||||
p = _find_free_port()
|
||||
assert _is_port_free(p), "freshly-allocated port should be free"
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", p))
|
||||
s.listen(1)
|
||||
try:
|
||||
assert not _is_port_free(p), "_is_port_free must return False while bound"
|
||||
finally:
|
||||
s.close()
|
||||
print("PASS test_is_port_free")
|
||||
|
||||
|
||||
def test_write_env_value():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env = os.path.join(tmp, ".env")
|
||||
with open(env, "w") as f:
|
||||
f.write("A=1\nB=2\nC=3\n# comment\n")
|
||||
_write_env_value(env, "B", "999")
|
||||
assert _read_env_value(env, "A") == "1"
|
||||
assert _read_env_value(env, "B") == "999"
|
||||
assert _read_env_value(env, "C") == "3"
|
||||
# New key appended.
|
||||
_write_env_value(env, "D", "new")
|
||||
assert _read_env_value(env, "D") == "new"
|
||||
# Comment line + sibling values preserved.
|
||||
with open(env) as f:
|
||||
body = f.read()
|
||||
assert "# comment" in body, "comment line dropped"
|
||||
assert "A=1" in body and "C=3" in body
|
||||
print("PASS test_write_env_value")
|
||||
|
||||
|
||||
# --- Test 2: stop_all reaps an active runtime (real spawn). ---
|
||||
async def test_stop_all_kills_active():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
port = _find_free_port()
|
||||
ws = _make_fake_workspace(tmp, port)
|
||||
m = AppRuntimeManager()
|
||||
rt = await m.attach("ws1", ws)
|
||||
assert rt.running, "runtime should be running after attach"
|
||||
pid = rt.process.pid
|
||||
# Wait for the child python to actually bind the port.
|
||||
for _ in range(40):
|
||||
if not _is_port_free(port):
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
else:
|
||||
raise AssertionError(f"fake child never bound on {port}")
|
||||
killed = await m.stop_all()
|
||||
assert killed >= 1, f"stop_all reported {killed} reaped"
|
||||
# Bash + python child must be gone within the grace window.
|
||||
for _ in range(60):
|
||||
if not _pid_alive(pid):
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
else:
|
||||
raise AssertionError(f"pid {pid} still alive after stop_all")
|
||||
# Port must be released too.
|
||||
for _ in range(40):
|
||||
if _is_port_free(port):
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
else:
|
||||
raise AssertionError(f"port {port} not released after stop_all")
|
||||
assert not m.runtimes and not m._idle_lru, "manager should be empty after stop_all"
|
||||
print("PASS test_stop_all_kills_active")
|
||||
|
||||
|
||||
# --- Test 3: stop_all reaps an idle (LRU + SIGSTOP'd) runtime. ---
|
||||
async def test_stop_all_kills_idle():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
port = _find_free_port()
|
||||
ws = _make_fake_workspace(tmp, port)
|
||||
m = AppRuntimeManager()
|
||||
rt = await m.attach("ws-idle", ws)
|
||||
pid = rt.process.pid
|
||||
# Detach -> moves into LRU + SIGSTOP'd. If stop_all forgets to
|
||||
# SIGCONT before SIGTERM, the kill queues and the process hangs.
|
||||
await m.detach("ws-idle")
|
||||
assert "ws-idle" in m._idle_lru, "should be in idle LRU"
|
||||
# Confirm the process is suspended (T state on Linux, T on darwin).
|
||||
# Skip the OS check; just rely on the eventual kill working.
|
||||
killed = await m.stop_all()
|
||||
assert killed == 1
|
||||
for _ in range(60):
|
||||
if not _pid_alive(pid):
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
else:
|
||||
raise AssertionError("idle process never died, stop_all probably didn't SIGCONT first")
|
||||
for _ in range(40):
|
||||
if _is_port_free(port):
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
else:
|
||||
raise AssertionError("port from idle runtime not released")
|
||||
print("PASS test_stop_all_kills_idle")
|
||||
|
||||
|
||||
# --- Test 4: persisted port collision triggers .env rewrite + new spawn. ---
|
||||
async def test_port_collision_reallocates_env():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
squatted_port = _find_free_port()
|
||||
ws = _make_fake_workspace(tmp, squatted_port)
|
||||
# Squat the persisted port so the runtime can't use it.
|
||||
squatter = socket.socket()
|
||||
squatter.bind(("127.0.0.1", squatted_port))
|
||||
squatter.listen(1)
|
||||
try:
|
||||
m = AppRuntimeManager()
|
||||
rt = await m.attach("ws-collide", ws)
|
||||
# Wait for either spawn-failure or new port binding.
|
||||
for _ in range(40):
|
||||
if rt.frontend_port and rt.frontend_port != squatted_port:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
assert rt.frontend_port != squatted_port, \
|
||||
f"frontend_port should have changed from {squatted_port}, got {rt.frontend_port}"
|
||||
# .env should reflect the new port (so run.sh and subsequent
|
||||
# restarts pick it up too).
|
||||
written = _read_env_value(os.path.join(ws, ".env"), "FRONTEND_PORT")
|
||||
assert written == str(rt.frontend_port), \
|
||||
f".env not rewritten; expected {rt.frontend_port}, found {written}"
|
||||
# Sibling .env keys untouched.
|
||||
assert _read_env_value(os.path.join(ws, ".env"), "SOMETHING_ELSE") == "untouched"
|
||||
assert _read_env_value(os.path.join(ws, ".env"), "TRAILING") == "keep"
|
||||
await m.stop_all()
|
||||
finally:
|
||||
squatter.close()
|
||||
print("PASS test_port_collision_reallocates_env")
|
||||
|
||||
|
||||
# --- Test 5: stop_all is idempotent. ---
|
||||
async def test_stop_all_idempotent():
|
||||
m = AppRuntimeManager()
|
||||
n = await m.stop_all()
|
||||
assert n == 0
|
||||
n = await m.stop_all()
|
||||
assert n == 0
|
||||
print("PASS test_stop_all_idempotent")
|
||||
|
||||
|
||||
# --- Test 6: vite-like grandchild dies even with EXIT-only trap. ---
|
||||
async def test_descendant_tree_killed_despite_exit_only_trap():
|
||||
"""Regression for the actual prod bug: webapp_template run.sh has only
|
||||
`trap cleanup EXIT` (no TERM), so a flat SIGTERM to bash exits bash
|
||||
silently and reparents the vite/uvicorn grandchild to PID 1. stop()
|
||||
must walk the descendant tree to nuke the grandchild explicitly."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
port = _find_free_port()
|
||||
ws = _make_fake_workspace(tmp, port)
|
||||
m = AppRuntimeManager()
|
||||
rt = await m.attach("ws-tree", ws)
|
||||
bash_pid = rt.process.pid
|
||||
# Wait until the python grandchild is actually listening on the port,
|
||||
# so we know it exists as a separate process.
|
||||
for _ in range(60):
|
||||
if not _is_port_free(port):
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
else:
|
||||
raise AssertionError("grandchild never bound the port")
|
||||
# Find the grandchild PID via pgrep -P (same call our walker uses).
|
||||
out = subprocess.run(
|
||||
["pgrep", "-P", str(bash_pid)],
|
||||
capture_output=True, text=True, timeout=2,
|
||||
)
|
||||
grand_pids = [int(p) for p in out.stdout.split() if p.strip().isdigit()]
|
||||
assert grand_pids, "expected at least one bash child"
|
||||
# The python process may be one further level down (`python -c ...` is
|
||||
# the leaf, bash spawned via `&` puts it directly under bash).
|
||||
all_descendants: list[int] = []
|
||||
def collect(pid: int) -> None:
|
||||
r = subprocess.run(
|
||||
["pgrep", "-P", str(pid)],
|
||||
capture_output=True, text=True, timeout=2,
|
||||
)
|
||||
for line in r.stdout.split():
|
||||
if line.strip().isdigit():
|
||||
pid_i = int(line)
|
||||
all_descendants.append(pid_i)
|
||||
collect(pid_i)
|
||||
for g in grand_pids:
|
||||
all_descendants.append(g)
|
||||
collect(g)
|
||||
await m.stop_all()
|
||||
# Every descendant must be gone, not just bash.
|
||||
for _ in range(80):
|
||||
still_alive = [p for p in all_descendants if _pid_alive(p)]
|
||||
if not still_alive:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
else:
|
||||
raise AssertionError(
|
||||
f"descendants still alive after stop_all: {still_alive} "
|
||||
"(EXIT-only trap let them escape)"
|
||||
)
|
||||
for _ in range(40):
|
||||
if _is_port_free(port):
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
else:
|
||||
raise AssertionError(f"port {port} still held by ghost grandchild")
|
||||
print("PASS test_descendant_tree_killed_despite_exit_only_trap")
|
||||
|
||||
|
||||
async def main():
|
||||
test_is_port_free()
|
||||
test_write_env_value()
|
||||
await test_stop_all_idempotent()
|
||||
await test_stop_all_kills_active()
|
||||
await test_stop_all_kills_idle()
|
||||
await test_port_collision_reallocates_env()
|
||||
await test_descendant_tree_killed_despite_exit_only_trap()
|
||||
print("\nALL TESTS PASSED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1200,6 +1200,46 @@ app.on('window-all-closed', () => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
// Ask the backend to reap every per-app subprocess (bash run.sh / vite /
|
||||
// uvicorn descendants) BEFORE we SIGTERM the backend itself. SIGTERM on
|
||||
// the backend PID doesn't propagate to those children, so without this
|
||||
// they reparent to PID 1 and squat on the workspace's .env-pinned ports,
|
||||
// breaking the NEXT launch's app reload. Fire-and-forget with a hard
|
||||
// timeout so a wedged backend can't block quit indefinitely.
|
||||
function postShutdownAllApps(timeoutMs = 2000) {
|
||||
return new Promise((resolve) => {
|
||||
if (!backendPort) return resolve();
|
||||
const req = http.request({
|
||||
hostname: '127.0.0.1',
|
||||
port: backendPort,
|
||||
path: '/api/outputs/shutdown-all',
|
||||
method: 'POST',
|
||||
headers: authToken
|
||||
? { 'Authorization': `Bearer ${authToken}`, 'Content-Length': 0 }
|
||||
: { 'Content-Length': 0 },
|
||||
timeout: timeoutMs,
|
||||
}, (res) => {
|
||||
res.on('data', () => {});
|
||||
res.on('end', resolve);
|
||||
});
|
||||
req.on('error', () => resolve());
|
||||
req.on('timeout', () => { try { req.destroy(); } catch (_) {} resolve(); });
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
let drainingForQuit = false;
|
||||
app.on('before-quit', async (event) => {
|
||||
if (drainingForQuit) return;
|
||||
event.preventDefault();
|
||||
drainingForQuit = true;
|
||||
try {
|
||||
await postShutdownAllApps(2000);
|
||||
} catch (_) {}
|
||||
app.quit();
|
||||
});
|
||||
|
||||
|
||||
app.on('will-quit', () => {
|
||||
if (!isDev) killBackend();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
const TRUSTED_API = `${API_BASE}/tools/trusted-sensitive-paths`;
|
||||
|
||||
// Mirrors the backend _SENSITIVE_PATH_INFO mapping. Kept here intentionally
|
||||
// (rather than fetched) because the user-facing label is the only part the
|
||||
// settings page renders, and a static dictionary keeps the page snappy and
|
||||
// works offline. If a pattern is unknown (older backend), fall back to the
|
||||
// raw pattern string.
|
||||
const PATTERN_LABELS: Record<string, string> = {
|
||||
'*/.ssh': 'SSH folder (~/.ssh)',
|
||||
'*/.ssh/*': 'SSH folder (~/.ssh)',
|
||||
'*/.aws/*': 'AWS credentials (~/.aws)',
|
||||
'*/.config/gcloud/*': 'Google Cloud credentials',
|
||||
'*/.kube/*': 'Kubernetes config (~/.kube)',
|
||||
'*/.gnupg/*': 'GPG encryption keys',
|
||||
'*/.docker/config*': 'Docker credentials',
|
||||
'*/.zshrc': 'Shell startup file (.zshrc)',
|
||||
'*/.bashrc': 'Shell startup file (.bashrc)',
|
||||
'*/.bash_profile': 'Shell startup file (.bash_profile)',
|
||||
'*/.profile': 'Shell startup file (.profile)',
|
||||
'*/.zprofile': 'Shell startup file (.zprofile)',
|
||||
'*/.zshenv': 'Shell environment file (.zshenv)',
|
||||
'*/.gitconfig': 'Global Git config',
|
||||
'*/.npmrc': 'npm auth file (~/.npmrc)',
|
||||
'*/.pypirc': 'PyPI auth file (~/.pypirc)',
|
||||
'*/.netrc': 'Stored login info (~/.netrc)',
|
||||
'*/Library/Keychains/*': 'macOS Keychain',
|
||||
'/etc/*': 'System config (/etc)',
|
||||
'/private/etc/*': 'System config (/etc)',
|
||||
'/System/*': 'macOS system folder',
|
||||
'/usr/local/etc/*': 'System config (/usr/local/etc)',
|
||||
'/etc/sudoers': 'Sudo permissions (/etc/sudoers)',
|
||||
'/etc/sudoers.d/*': 'Sudo permissions (/etc/sudoers.d)',
|
||||
'/etc/passwd': 'System user list (/etc/passwd)',
|
||||
'/etc/shadow': 'System password file (/etc/shadow)',
|
||||
};
|
||||
|
||||
export const TrustedFilePatterns: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const [patterns, setPatterns] = useState<string[] | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(TRUSTED_API);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setPatterns(Array.isArray(data.patterns) ? data.patterns : []);
|
||||
} catch {
|
||||
setPatterns([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const revoke = useCallback(async (pat: string) => {
|
||||
if (!patterns) return;
|
||||
const next = patterns.filter((p) => p !== pat);
|
||||
setPatterns(next);
|
||||
try {
|
||||
await fetch(TRUSTED_API, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ patterns: next }),
|
||||
});
|
||||
} catch {
|
||||
// On failure, reload from server so UI matches truth.
|
||||
load();
|
||||
}
|
||||
}, [patterns, load]);
|
||||
|
||||
if (patterns === null) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600 }}>
|
||||
Trusted file patterns
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.8rem', color: c.text.secondary, lineHeight: 1.45 }}>
|
||||
Files like SSH keys and shell startup files normally ask before each write, even when you've set Write to "always allow". Patterns you've chosen to always allow appear below. Remove one to start asking again.
|
||||
</Typography>
|
||||
{patterns.length === 0 ? (
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.tertiary, fontStyle: 'italic', mt: 0.5 }}>
|
||||
No trusted patterns yet. You'll see a checkbox the first time the agent tries to write a sensitive file.
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', border: `1px solid ${c.border.subtle}`, borderRadius: 1.5, overflow: 'hidden', mt: 0.5 }}>
|
||||
{patterns.map((pat, idx) => (
|
||||
<Box
|
||||
key={pat}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
borderTop: idx === 0 ? 'none' : `1px solid ${c.border.subtle}`,
|
||||
bgcolor: c.bg.surface,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.82rem', color: c.text.primary, fontWeight: 500 }}>
|
||||
{PATTERN_LABELS[pat] || pat}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.tertiary, fontFamily: c.font.mono, mt: 0.15 }}>
|
||||
{pat}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => revoke(pat)}
|
||||
aria-label={`Remove ${PATTERN_LABELS[pat] || pat}`}
|
||||
sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error } }}
|
||||
>
|
||||
<DeleteOutlineIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrustedFilePatterns;
|
||||
@@ -499,8 +499,8 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
if (!isDraft) dispatch(updateThinkingLevel({ sessionId: id, level }));
|
||||
}, [id, isDraft, dispatch]);
|
||||
|
||||
const handleApprove = (requestId: string, updatedInput?: Record<string, any>) => {
|
||||
dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput }));
|
||||
const handleApprove = (requestId: string, updatedInput?: Record<string, any>, trustPattern?: boolean) => {
|
||||
dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput, trustPattern }));
|
||||
};
|
||||
|
||||
const handleDeny = (requestId: string, message?: string) => {
|
||||
|
||||
@@ -6,6 +6,9 @@ import TextField from '@mui/material/TextField';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
@@ -180,7 +183,7 @@ function getMcpInputSummary(actionName: string, toolInput: Record<string, any>):
|
||||
|
||||
interface Props {
|
||||
request: ApprovalRequest;
|
||||
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
|
||||
onApprove: (requestId: string, updatedInput?: Record<string, any>, trustPattern?: boolean) => void;
|
||||
onDeny: (requestId: string, message?: string) => void;
|
||||
}
|
||||
|
||||
@@ -326,7 +329,7 @@ type Answers = Record<number, string | string[]>;
|
||||
|
||||
export interface QuestionFormProps {
|
||||
request: ApprovalRequest;
|
||||
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
|
||||
onApprove: (requestId: string, updatedInput?: Record<string, any>, trustPattern?: boolean) => void;
|
||||
onDeny: (requestId: string, message?: string) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
@@ -589,12 +592,14 @@ const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) =>
|
||||
const [denyMessage, setDenyMessage] = useState('');
|
||||
const [showDenyInput, setShowDenyInput] = useState(false);
|
||||
const [detailsExpanded, setDetailsExpanded] = useState(false);
|
||||
const [trustPattern, setTrustPattern] = useState(false);
|
||||
|
||||
const parsed = useMemo(() => parseMcpToolName(request.tool_name), [request.tool_name]);
|
||||
const meta = useMcpToolMeta(parsed);
|
||||
|
||||
const accentColor = meta.integration?.color || c.status.warning;
|
||||
const summary = parsed.isMcp ? getMcpInputSummary(parsed.actionName, request.tool_input) : '';
|
||||
const isSensitive = !!request.sensitive_pattern;
|
||||
|
||||
if (!parsed.isMcp) {
|
||||
return (
|
||||
@@ -613,7 +618,7 @@ const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) =>
|
||||
{getToolIcon(request.tool_name)}
|
||||
</Box>
|
||||
<Typography sx={{ color: c.status.warning, fontWeight: 700, fontSize: '0.85rem' }}>
|
||||
Permission Required
|
||||
{isSensitive ? 'Sensitive file' : 'Permission Required'}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={request.tool_name}
|
||||
@@ -630,10 +635,62 @@ const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) =>
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{isSensitive && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
alignItems: 'flex-start',
|
||||
bgcolor: 'rgba(181,51,51,0.08)',
|
||||
border: '1px solid rgba(181,51,51,0.25)',
|
||||
borderRadius: 1.5,
|
||||
px: 1.25,
|
||||
py: 1,
|
||||
mb: 1.25,
|
||||
}}
|
||||
>
|
||||
<WarningAmberIcon sx={{ fontSize: 18, color: c.status.error, mt: 0.1, flexShrink: 0 }} />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.82rem', lineHeight: 1.3 }}>
|
||||
This file is sensitive: {request.sensitive_label}
|
||||
</Typography>
|
||||
{request.sensitive_why && (
|
||||
<Typography sx={{ color: c.text.secondary, fontSize: '0.78rem', lineHeight: 1.35, mt: 0.3 }}>
|
||||
{request.sensitive_why} OpenSwarm asks every time because a bad change here is hard to undo. Approve only if you asked for this.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<ToolPreview request={request} tokens={c} />
|
||||
</Box>
|
||||
|
||||
{isSensitive && (
|
||||
<FormControlLabel
|
||||
sx={{
|
||||
mb: 1,
|
||||
ml: 0,
|
||||
alignItems: 'flex-start',
|
||||
'& .MuiFormControlLabel-label': { fontSize: '0.78rem', color: c.text.secondary, lineHeight: 1.35, pt: 0.5 },
|
||||
}}
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={trustPattern}
|
||||
onChange={(e) => setTrustPattern(e.target.checked)}
|
||||
sx={{ p: 0.5, color: c.text.tertiary, '&.Mui-checked': { color: c.status.warning } }}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<>
|
||||
Always allow files like this <strong>({request.sensitive_label})</strong>. You can change this later in Settings → Trusted file patterns.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDenyInput && (
|
||||
<TextField
|
||||
placeholder="Reason for denying (optional)..."
|
||||
@@ -657,7 +714,7 @@ const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) =>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<CheckIcon />}
|
||||
onClick={() => onApprove(request.id)}
|
||||
onClick={() => onApprove(request.id, undefined, isSensitive && trustPattern)}
|
||||
sx={{ bgcolor: c.status.success, '&:hover': { bgcolor: '#1e4d15' }, fontWeight: 600, fontSize: '0.8rem' }}
|
||||
>
|
||||
Approve
|
||||
@@ -896,7 +953,7 @@ interface ToolGroup {
|
||||
|
||||
interface BatchApprovalBarProps {
|
||||
requests: ApprovalRequest[];
|
||||
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
|
||||
onApprove: (requestId: string, updatedInput?: Record<string, any>, trustPattern?: boolean) => void;
|
||||
onDeny: (requestId: string, message?: string) => void;
|
||||
}
|
||||
|
||||
@@ -1042,7 +1099,7 @@ interface GroupRowProps {
|
||||
group: ToolGroup;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
|
||||
onApprove: (requestId: string, updatedInput?: Record<string, any>, trustPattern?: boolean) => void;
|
||||
onDeny: (requestId: string, message?: string) => void;
|
||||
onApproveGroup: () => void;
|
||||
onDenyGroup: () => void;
|
||||
|
||||
@@ -55,6 +55,7 @@ import { setChecking, setUpdateError, setInstalling } from '@/shared/state/updat
|
||||
import { fetchModes } from '@/shared/state/modesSlice';
|
||||
import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext';
|
||||
import DirectoryBrowser from '@/app/components/DirectoryBrowser';
|
||||
import TrustedFilePatterns from '@/app/components/TrustedFilePatterns';
|
||||
import { CommandsContent } from '@/app/pages/Commands/Commands';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import PlanPicker from '@/app/components/PlanPicker';
|
||||
@@ -2194,8 +2195,9 @@ const Settings: React.FC = () => {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Restart the onboarding tour. Wipes local progress so the
|
||||
Get-Started panel re-opens at step 1 with no completed steps. */}
|
||||
<TrustedFilePatterns />
|
||||
|
||||
|
||||
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography sx={{ ...labelSx, mb: 0.25 }}>Onboarding tour</Typography>
|
||||
|
||||
@@ -45,6 +45,9 @@ export interface ApprovalRequest {
|
||||
tool_name: string;
|
||||
tool_input: Record<string, any>;
|
||||
created_at: string;
|
||||
sensitive_pattern?: string | null;
|
||||
sensitive_label?: string | null;
|
||||
sensitive_why?: string | null;
|
||||
}
|
||||
|
||||
export interface MessageBranch {
|
||||
@@ -415,16 +418,18 @@ export const handleApproval = createAsyncThunk(
|
||||
behavior,
|
||||
message,
|
||||
updatedInput,
|
||||
trustPattern,
|
||||
}: {
|
||||
requestId: string;
|
||||
behavior: 'allow' | 'deny';
|
||||
message?: string;
|
||||
updatedInput?: Record<string, any>;
|
||||
trustPattern?: boolean;
|
||||
}) => {
|
||||
const res = await fetch(`${AGENTS_API}/approval`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ request_id: requestId, behavior, message, updated_input: updatedInput }),
|
||||
body: JSON.stringify({ request_id: requestId, behavior, message, updated_input: updatedInput, trust_pattern: !!trustPattern }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Approval request failed (${res.status})`);
|
||||
|
||||
@@ -541,6 +541,9 @@ class WebSocketManager {
|
||||
tool_name: data.tool_name,
|
||||
tool_input: data.tool_input,
|
||||
created_at: new Date().toISOString(),
|
||||
sensitive_pattern: data.sensitive_pattern ?? null,
|
||||
sensitive_label: data.sensitive_label ?? null,
|
||||
sensitive_why: data.sensitive_why ?? null,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user