mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 19:43:15 +00:00
* fix(webhooks): fall back to vision model for Slack/Linear image threads Re-land upstream #1626 onto the modular webhook structure. When a Slack mention or Linear issue carries images but the resolved model is text-only, fall back to a vision-capable model instead of dropping the images. Re-points default_vision_model_pair at the fork's image-capable models (Opus 4.8 default, else any supports_images model) rather than upstream's openai:/anthropic: provider filter. Refs #80, upstream #1626 * fix(slack): persist trace_message_ts so web-handoff updates the trace reply Re-land upstream #1630 onto the modular structure. The first-mention store_slack_run_mapping call did not pass trace_message_ts, so it was never persisted (nothing to preserve from on first mention) and _notify_slack_web_handoff always skipped the trace-reply update on web handoff. Pass it through and cover it with a test. Refs #80, upstream #1630 * feat(slack): include channel context in Slack prompts Re-land upstream #1633 onto the modular structure. Fetch cached Slack channel metadata once per event (_get_slack_channel_context) and thread it through the docs-plz gate, repo resolution, and process_slack_mention so prompts carry the channel name and a clearly-marked untrusted channel description. Avoids duplicate conversations.info calls. Refs #80, upstream #1633 * feat(tools): add slack_start_new_thread breakout tool Re-land upstream #1638 onto the modular structure. Adds the slack_start_new_thread tool (posts a top-level Slack message and dispatches a fresh agent run for a broken-out task via the durable dispatch_agent_run contract), wires it into the agent tool list and tools/__init__, adds prompt guidance, and excludes it from plan mode so it can't bypass the approval flow. Tool imports only live modules. Refs #80, upstream #1638 * feat(plan): notify Slack on plan approval Re-land upstream #1632 onto the modular structure. When a plan is approved via the dashboard approve endpoint, post a thread reply to the originating Slack thread noting the comment count and approver, after the follow-up run is dispatched. Slack post failures never break approval. Adapted to the fork's approve_plan (no plan_markdown read). Refs #80, upstream #1632 * feat(plan): publish plans from sandbox files Re-land upstream #1635 onto the modular structure, completing the partially-ported change so dev is internally consistent. save_plan now takes a plan_file_path, reads the agent-authored Markdown file from /workspace/plans/ (validating extension/location/UTF-8/size) and publishes it, instead of taking a plan_markdown string. Removes write_file/edit_file from PLAN_MODE_EXCLUDED_TOOLS so the agent can author the plan file, updates enter_plan_mode/reject_plan guidance and the e2e fake LLM. Skips the #1610-only update_plan hunk (not on dev). Refs #80, upstream #1635 * fix(security): SSRF-harden server-side image fetch + stop logging raw image URLs INJ-01 (high): fetch_image_block used follow_redirects=True with no per-hop revalidation and discarded the resolved-IP pin, so an attacker-authored Slack/ Linear image URL could 302-redirect the fetch to an internal host / cloud metadata endpoint (blind SSRF), and DNS-rebinding could bypass the one-shot is_url_safe check. Route image fetches through the same per-hop resolve+pin+ revalidate loop the http_request tool uses, lifted into url_safety as the shared request_with_safe_redirects. Also strip the per-host Slack/Linear bearer token on redirect so it can't be replayed to a redirect target. SC-1 (low): linear.py logged full image URLs (which can carry signed tokens) at DEBUG; multimodal logged them at INFO on every fetch. Log host-only. Sink lived in multimodal.py (unchanged by the feature work) but PR #128 widened its reach by no longer dropping images for text-only models. Fixing on the base branch so #130/#129 inherit it on rebase. Adds fetch_image_block SSRF regression tests (redirect-to-internal blocked; auth stripped on redirect).
116 lines
4.4 KiB
Python
116 lines
4.4 KiB
Python
"""Tool: ``save_plan``. Publish the sandbox plan file for review.
|
|
|
|
Reads the Markdown plan file the agent created in the sandbox and publishes it to
|
|
the plan-review page, where the user and collaborators read it, comment inline,
|
|
and approve or request changes. Available in plan mode (it does not modify the
|
|
repository under review).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import Mapping
|
|
from typing import Any
|
|
|
|
from langgraph.config import get_config
|
|
|
|
from ..dashboard.plan_store import PLAN_FILE_DIRECTORY, PLAN_STATUS_READY, save_plan_content
|
|
from ..utils.sandbox_state import get_sandbox_backend
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_MAX_PLAN_LINES = 20_000
|
|
_MARKDOWN_EXTENSIONS = (".md", ".markdown")
|
|
|
|
|
|
async def save_plan(plan_file_path: str) -> dict[str, Any]:
|
|
"""Publish a Markdown plan file from the sandbox for review.
|
|
|
|
Use this in plan mode once your plan is ready. First create a Markdown file
|
|
under ``/workspace/plans/`` using a dated, descriptive filename, then pass
|
|
that file path here. The file contents are published to the plan-review page
|
|
linked in the conversation, where the user (the owner) and any collaborators
|
|
can read it, leave inline comments, and then approve it or request changes.
|
|
Call it again to publish a revised file when addressing feedback.
|
|
|
|
Write the plan in standard Markdown — headings, bullet/numbered lists, and
|
|
fenced code blocks all render. Keep it concise and high level, focusing on
|
|
approach, decisions/tradeoffs, risks, and verification; avoid file/function
|
|
details unless they are unusually tricky or controversial.
|
|
|
|
Args:
|
|
plan_file_path: Path to the Markdown plan file in the sandbox.
|
|
|
|
Returns:
|
|
``{success: True, path}`` on success, or ``{success: False, error}``.
|
|
"""
|
|
if not isinstance(plan_file_path, str):
|
|
return {"success": False, "error": "plan_file_path must be a string"}
|
|
path = plan_file_path.strip()
|
|
if not path:
|
|
return {"success": False, "error": "plan_file_path cannot be empty"}
|
|
if not _is_markdown_path(path):
|
|
return {
|
|
"success": False,
|
|
"error": f"plan_file_path must point to a Markdown file in {PLAN_FILE_DIRECTORY}",
|
|
}
|
|
|
|
try:
|
|
config = get_config()
|
|
except Exception:
|
|
config = {}
|
|
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
|
|
thread_id = configurable.get("thread_id") if isinstance(configurable, dict) else None
|
|
if not thread_id:
|
|
return {"success": False, "error": "no thread_id in run config"}
|
|
|
|
try:
|
|
content = (await _read_plan_file(str(thread_id), path)).strip()
|
|
if not content:
|
|
return {"success": False, "error": "plan file cannot be empty"}
|
|
await _save(str(thread_id), content, path)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.exception("save_plan failed for thread %s", thread_id)
|
|
return {"success": False, "error": f"failed to save plan: {exc}"}
|
|
return {"success": True, "path": path}
|
|
|
|
|
|
async def _save(thread_id: str, content: str, path: str) -> None:
|
|
await save_plan_content(
|
|
thread_id, markdown=content, status=PLAN_STATUS_READY, plan_file_path=path
|
|
)
|
|
|
|
|
|
async def _read_plan_file(thread_id: str, path: str) -> str:
|
|
backend = await get_sandbox_backend(thread_id)
|
|
result = await backend.aread(path, offset=0, limit=_MAX_PLAN_LINES)
|
|
error = _value(result, "error")
|
|
if error:
|
|
raise ValueError(error)
|
|
file_data = _value(result, "file_data")
|
|
if file_data is None:
|
|
raise ValueError("plan file could not be read")
|
|
encoding = _value(file_data, "encoding")
|
|
if encoding is not None and encoding != "utf-8":
|
|
raise ValueError("plan file must be UTF-8 text")
|
|
content = _value(file_data, "content")
|
|
if not isinstance(content, str):
|
|
raise ValueError("plan file content was not text")
|
|
if content.count("\n") + 1 >= _MAX_PLAN_LINES:
|
|
raise ValueError("plan file is too large")
|
|
return content
|
|
|
|
|
|
def _value(value: Any, key: str) -> Any:
|
|
if isinstance(value, Mapping):
|
|
return value.get(key)
|
|
return getattr(value, key, None)
|
|
|
|
|
|
def _is_markdown_path(path: str) -> bool:
|
|
if "\x00" in path or not path.startswith(f"{PLAN_FILE_DIRECTORY}/"):
|
|
return False
|
|
filename = path.removeprefix(f"{PLAN_FILE_DIRECTORY}/")
|
|
if not filename or "/" in filename:
|
|
return False
|
|
return filename.lower().endswith(_MARKDOWN_EXTENSIONS)
|