Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
d85da95
M5: Python agent sidecar + SwiftUI agent panel
claude May 19, 2026
ceaf3f2
M5 review fixes + tests
claude May 19, 2026
1d8eb1f
fix(macos): stabilize m5 agent sessions
kalil0321 May 19, 2026
8a6f72a
fix(macos): trust ca before device capture
kalil0321 May 20, 2026
2863877
feat(macos): add theme palette, shared pill controls, markdown renderer
kalil0321 May 20, 2026
88e3dc3
feat(macos): redesign main window with near-black UI and command palette
kalil0321 May 20, 2026
fba86ae
feat(macos): inspector preview tab and native segmented tabs
kalil0321 May 20, 2026
e856ab9
feat(macos): markdown rendering and cleaner agent panel
kalil0321 May 20, 2026
0d59f8a
chore(macos): commit Package.resolved to pin dependencies
kalil0321 May 20, 2026
2b3fb48
refactor(macos): drop keyboard shortcuts and agent panel animation
kalil0321 May 20, 2026
7407155
fix(macos): use AppKit text field wrappers for palette and agent inputs
kalil0321 May 20, 2026
92d995b
fix(macos): set .regular activation policy so swift run inputs work
kalil0321 May 20, 2026
6177d9c
fix(macos): force explicit white on text views to stop invisible text
kalil0321 May 20, 2026
0aeb228
feat(macos): self-contained agent sidecar — embed python runtime in .app
kalil0321 May 20, 2026
695ea2d
fix(macos): wrap agent composer text instead of overflowing horizontally
kalil0321 May 20, 2026
b39af3c
refactor(macos): drop bottom status bar, surface state + CA in action…
kalil0321 May 20, 2026
9abdb13
feat(agent): stream assistant replies incrementally instead of bulk
kalil0321 May 20, 2026
7c5727d
feat(macos): show user messages in the agent timeline and hide noisy …
kalil0321 May 20, 2026
a978470
fix(agent): import StreamEvent from claude_agent_sdk.types submodule
kalil0321 May 20, 2026
ff7a97e
refactor(macos): render assistant markdown with swift-markdown-ui
kalil0321 May 20, 2026
2fef6ec
feat(macos): support deleting captured flows
kalil0321 May 20, 2026
da73591
feat(macos): pick which flows go to the agent + delete from row conte…
kalil0321 May 20, 2026
8c20030
fix(agent): run sidecar with bypassPermissions so reads don't prompt
kalil0321 May 20, 2026
4b3f96e
feat(macos): refine tool call / result rows in the agent timeline
kalil0321 May 20, 2026
978696c
feat(macos): tap an agent-written file to open it in a viewer sheet
kalil0321 May 20, 2026
23c0e97
feat(macos): native syntax highlighter for assistant code blocks
kalil0321 May 20, 2026
1fb23dd
feat(macos): full markdown theme — tables, code header, lists, task l…
kalil0321 May 20, 2026
67cce06
feat(agent): persist sessions to disk and resume via Claude SDK sessi…
kalil0321 May 20, 2026
e46d031
feat(macos): cards layout + agent sessions history view
kalil0321 May 20, 2026
4e46823
refactor(macos): redesign traffic + inspector rows for narrow widths,…
kalil0321 May 20, 2026
ae5b326
refactor(macos): drop divider between action bar and cards
kalil0321 May 20, 2026
afcee6d
fix(macos): align card headers, unify backgrounds, raise traffic card…
kalil0321 May 20, 2026
da71445
fix(macos): traffic card auto-grows when inspector opens to prevent o…
kalil0321 May 20, 2026
e0b1249
refactor(macos): slim down ActionBar, move filter + delete-all into t…
kalil0321 May 20, 2026
640b582
style(macos): warmer dark grey palette instead of near-black
kalil0321 May 20, 2026
bb3502d
refactor(macos): replace outer HSplitView with custom transparent split
kalil0321 May 20, 2026
a8dbbdf
feat(macos): inline filter chips + custom text filter inside the traf…
kalil0321 May 20, 2026
7a1f3bc
refactor(macos): move filter chips back inside a single popover button
kalil0321 May 20, 2026
f1d3d49
feat(macos): show request duration next to the timestamp on each row
kalil0321 May 20, 2026
d3eea63
fix(macos): harden AgentSidecar port discovery — require newline, rec…
kalil0321 May 20, 2026
e2c9c22
fix(agent): emit TextBlocks when StreamEvent SDK fallback is in use
kalil0321 May 20, 2026
b29f248
fix(macos): keep in-memory + persisted flow state in sync on delete/c…
kalil0321 May 20, 2026
73c0046
fix(macos): inspector — nil HTML baseURL + don't gate text copy on co…
kalil0321 May 20, 2026
539ab40
fix(macos): truncate bodies on UTF-8 boundary + scope history record …
kalil0321 May 20, 2026
a04add5
refactor(macos): drop dead palette footer + dedupe HTTP color helpers
kalil0321 May 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "rae-agent"
version = "0.1.0"
description = "ReverseAPI agent sidecar"
requires-python = ">=3.11"
dependencies = [
"websockets>=13.0",
"claude-agent-sdk>=0.1.48",
]

[project.scripts]
rae-agent = "rae_agent.server:main"

[tool.hatch.build.targets.wheel]
packages = ["rae_agent"]

[tool.ruff]
line-length = 100
target-version = "py311"
17 changes: 17 additions & 0 deletions backend/rae_agent/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from rae_agent.protocol import (
AgentEvent,
ChatRequest,
FlowSummary,
ProtocolError,
TargetLanguage,
sanitize_session_id,
)

__all__ = [
"AgentEvent",
"ChatRequest",
"FlowSummary",
"ProtocolError",
"TargetLanguage",
"sanitize_session_id",
]
65 changes: 65 additions & 0 deletions backend/rae_agent/prompts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from __future__ import annotations

from rae_agent.protocol import ChatRequest, TargetLanguage

LANGUAGE_HINTS = {
TargetLanguage.PYTHON: """
- Idiomatic Python 3.11+
- Use `httpx` for HTTP, prefer async if the flows look paginated or interactive
- Type hints with `from __future__ import annotations`
- Pydantic models when response shapes are stable
""",
TargetLanguage.TYPESCRIPT: """
- TypeScript 5+, ESM
- Use the global `fetch` API (no axios)
- Strict types, no `any`
- Export plain async functions; group them in a class only if state is required
""",
TargetLanguage.GO: """
- Modern Go 1.22+
- net/http standard client; no third-party HTTP libraries
- Errors wrapped with `%w`
- Structs with json tags
""",
}


SYSTEM_PROMPT = """You are ReverseAPI, an expert at reverse-engineering HTTP APIs from captured traffic.

You will be given:
- A user request describing the API client they want
- A JSON file at a known path containing captured HTTP flows (request + response, headers and bodies)
- A target language

Your job:
1. Read the flows file with the Read tool.
2. Identify the API endpoints involved in the user's request.
3. Detect authentication patterns (Bearer, cookies, custom headers).
4. Detect content negotiation, pagination, retries, rate limit headers.
5. Synthesise a clean, production-shaped client library in the target language.
6. Write each generated file using the Write tool, into the current working directory.
7. Briefly summarise what you produced and any assumptions you made.

Do NOT execute any code or make outbound HTTP calls. Do NOT install packages.
Keep generated code dependency-light and readable."""


def build_user_prompt(request: ChatRequest, flows_path: str) -> str:
hints = LANGUAGE_HINTS.get(request.target, "").strip()
lines = [
f"User request: {request.user_message}",
"",
f"Captured flows file: {flows_path}",
f"Number of flows: {len(request.flows)}",
f"Target language: {request.target.value}",
"",
"Language guidelines:",
hints,
]
if request.history:
lines.extend([
"",
"Recent conversation:",
*[f"- {item['role']}: {item['content']}" for item in request.history[-6:]],
])
return "\n".join(lines)
166 changes: 166 additions & 0 deletions backend/rae_agent/protocol.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
from __future__ import annotations

import re
from dataclasses import dataclass, field
from enum import Enum
from typing import Any


class TargetLanguage(str, Enum):
PYTHON = "python"
TYPESCRIPT = "typescript"
GO = "go"


class ProtocolError(Exception):
pass


_SAFE_ID_PATTERN = re.compile(r"^[A-Za-z0-9._\-]+$")


def sanitize_session_id(raw: str, fallback: str) -> str:
if not raw:
return fallback
candidate = raw.strip()
if not candidate or candidate in {".", ".."}:
return fallback
if "/" in candidate or "\\" in candidate or "\x00" in candidate:
return fallback
if not _SAFE_ID_PATTERN.fullmatch(candidate):
return fallback
return candidate[:128]


def _optional_float(value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError) as exc:
raise ProtocolError(f"expected float, got {value!r}") from exc


@dataclass
class FlowSummary:
id: str
scheme: str
method: str
url: str
request_headers: list[tuple[str, str]]
request_body: str | None
response_status: int | None
response_headers: list[tuple[str, str]]
response_body: str | None
started_at: float
finished_at: float | None

@classmethod
def from_payload(cls, payload: dict[str, Any]) -> "FlowSummary":
try:
return cls(
id=str(payload["id"]),
scheme=str(payload["scheme"]),
method=str(payload["method"]),
url=str(payload["url"]),
request_headers=[(str(k), str(v)) for k, v in payload.get("requestHeaders", [])],
request_body=payload.get("requestBody"),
response_status=payload.get("responseStatus"),
response_headers=[(str(k), str(v)) for k, v in payload.get("responseHeaders", [])],
response_body=payload.get("responseBody"),
started_at=float(payload.get("startedAt", 0.0)),
finished_at=_optional_float(payload.get("finishedAt")),
)
except (KeyError, TypeError, ValueError) as exc:
raise ProtocolError(f"invalid flow payload: {exc}") from exc


@dataclass
class ChatRequest:
id: str
user_message: str
target: TargetLanguage
flows: list[FlowSummary] = field(default_factory=list)
history: list[dict[str, str]] = field(default_factory=list)
# The Claude Agent SDK session id from a previous turn. When present we
# pass it as ClaudeAgentOptions.resume so the SDK can re-attach to its
# own persisted state instead of replaying our local history.
claude_session_id: str | None = None

@classmethod
def from_payload(cls, payload: dict[str, Any]) -> "ChatRequest":
if payload.get("type") != "chat":
raise ProtocolError("expected type=chat")
try:
target_value = str(payload.get("target", "python")).lower()
target = TargetLanguage(target_value)
except ValueError as exc:
raise ProtocolError(f"unsupported target language: {payload.get('target')!r}") from exc
flows = [FlowSummary.from_payload(f) for f in payload.get("flows", [])]
history = [
{"role": str(item.get("role", "user")), "content": str(item.get("content", ""))}
for item in payload.get("history", [])
]
raw_sid = payload.get("claudeSessionId") or payload.get("claude_session_id")
claude_session_id = str(raw_sid) if isinstance(raw_sid, str) and raw_sid else None
return cls(
id=str(payload.get("id", "")),
user_message=str(payload.get("message", "")),
target=target,
flows=flows,
history=history,
claude_session_id=claude_session_id,
)


@dataclass
class AgentEvent:
type: str
payload: dict[str, Any]

def to_dict(self) -> dict[str, Any]:
return {"type": self.type, **self.payload}

@classmethod
def assistant_text(cls, chat_id: str, text: str) -> "AgentEvent":
return cls(type="assistant_text", payload={"id": chat_id, "text": text})

@classmethod
def session_started(cls, chat_id: str, claude_session_id: str) -> "AgentEvent":
return cls(
type="session_started",
payload={"id": chat_id, "claudeSessionId": claude_session_id},
)

@classmethod
def assistant_text_chunk(cls, chat_id: str, text: str) -> "AgentEvent":
return cls(type="assistant_text_chunk", payload={"id": chat_id, "text": text})

@classmethod
def tool_use(cls, chat_id: str, name: str, tool_input: dict[str, Any]) -> "AgentEvent":
return cls(type="tool_use", payload={"id": chat_id, "name": name, "input": tool_input})

@classmethod
def tool_result(cls, chat_id: str, name: str, output: str, is_error: bool) -> "AgentEvent":
return cls(
type="tool_result",
payload={"id": chat_id, "name": name, "output": output, "is_error": is_error},
)

@classmethod
def file_written(cls, chat_id: str, path: str) -> "AgentEvent":
return cls(type="file_written", payload={"id": chat_id, "path": path})

@classmethod
def complete(cls, chat_id: str, workdir: str, files: list[str]) -> "AgentEvent":
return cls(
type="complete",
payload={"id": chat_id, "workdir": workdir, "files": files},
)

@classmethod
def error(cls, chat_id: str | None, message: str) -> "AgentEvent":
payload: dict[str, Any] = {"message": message}
if chat_id is not None:
payload["id"] = chat_id
return cls(type="error", payload=payload)
83 changes: 83 additions & 0 deletions backend/rae_agent/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
from __future__ import annotations

import asyncio
import json
import logging
import os
import sys
import tempfile
from pathlib import Path

import websockets

from rae_agent.protocol import AgentEvent, ChatRequest, ProtocolError
from rae_agent.session import run_chat

logger = logging.getLogger("rae_agent")


async def handle_connection(websocket, base_dir: Path) -> None:
try:
async for raw in websocket:
await _process(websocket, raw, base_dir)
except websockets.ConnectionClosed:
return


async def _process(websocket, raw: str | bytes, base_dir: Path) -> None:
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
await websocket.send(json.dumps(AgentEvent.error(None, f"invalid JSON: {exc}").to_dict()))
return

try:
request = ChatRequest.from_payload(payload)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Validate request.id before calling run_chat; path traversal sequences can escape base_dir when session directories are created.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/rae_agent/server.py, line 35:

<comment>Validate `request.id` before calling `run_chat`; path traversal sequences can escape `base_dir` when session directories are created.</comment>

<file context>
@@ -0,0 +1,76 @@
+        return
+
+    try:
+        request = ChatRequest.from_payload(payload)
+    except ProtocolError as exc:
+        await websocket.send(json.dumps(AgentEvent.error(None, str(exc)).to_dict()))
</file context>

except ProtocolError as exc:
await websocket.send(json.dumps(AgentEvent.error(None, str(exc)).to_dict()))
return

try:
async for event in run_chat(request, base_dir):
await websocket.send(json.dumps(event.to_dict()))
except ValueError as exc:
await websocket.send(json.dumps(AgentEvent.error(request.id, str(exc)).to_dict()))
except Exception as exc:
logger.exception("agent run failed")
await websocket.send(json.dumps(AgentEvent.error(request.id, str(exc)).to_dict()))


async def serve(host: str, port: int, base_dir: Path) -> None:
async def handler(websocket):
await handle_connection(websocket, base_dir)

async with websockets.serve(handler, host, port, max_size=64 * 1024 * 1024) as server:
sockets = list(server.sockets or [])
bound_port = sockets[0].getsockname()[1] if sockets else port
print(f"RAE_AGENT_LISTENING:{bound_port}", flush=True)
await asyncio.Future()


def resolve_base_dir() -> Path:
raw = os.environ.get("RAE_AGENT_WORKDIR")
if raw:
return Path(raw)
return Path(tempfile.gettempdir()) / "rae-agent-sessions"


def main() -> None:
logging.basicConfig(level=os.environ.get("RAE_AGENT_LOG", "INFO"))

host = os.environ.get("RAE_AGENT_HOST", "127.0.0.1")
port = int(os.environ.get("RAE_AGENT_PORT", "0"))
base_dir = resolve_base_dir()
base_dir.mkdir(parents=True, exist_ok=True)

try:
asyncio.run(serve(host, port, base_dir))
except KeyboardInterrupt:
sys.exit(0)


if __name__ == "__main__":
main()
Loading