oh-my-claude-sisyphus 3.2.5 → 3.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -2
- package/agents/scientist-high.md +1003 -0
- package/agents/scientist-low.md +232 -0
- package/agents/scientist.md +1180 -0
- package/bridge/__pycache__/gyoshu_bridge.cpython-310.pyc +0 -0
- package/bridge/gyoshu_bridge.py +846 -0
- package/commands/research.md +511 -0
- package/dist/agents/definitions.d.ts +9 -0
- package/dist/agents/definitions.d.ts.map +1 -1
- package/dist/agents/definitions.js +25 -0
- package/dist/agents/definitions.js.map +1 -1
- package/dist/agents/index.d.ts +2 -1
- package/dist/agents/index.d.ts.map +1 -1
- package/dist/agents/index.js +2 -1
- package/dist/agents/index.js.map +1 -1
- package/dist/agents/scientist.d.ts +16 -0
- package/dist/agents/scientist.d.ts.map +1 -0
- package/dist/agents/scientist.js +370 -0
- package/dist/agents/scientist.js.map +1 -0
- package/dist/lib/atomic-write.d.ts +29 -0
- package/dist/lib/atomic-write.d.ts.map +1 -0
- package/dist/lib/atomic-write.js +111 -0
- package/dist/lib/atomic-write.js.map +1 -0
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +4 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/python-repl/bridge-manager.d.ts +65 -0
- package/dist/tools/python-repl/bridge-manager.d.ts.map +1 -0
- package/dist/tools/python-repl/bridge-manager.js +478 -0
- package/dist/tools/python-repl/bridge-manager.js.map +1 -0
- package/dist/tools/python-repl/index.d.ts +40 -0
- package/dist/tools/python-repl/index.d.ts.map +1 -0
- package/dist/tools/python-repl/index.js +36 -0
- package/dist/tools/python-repl/index.js.map +1 -0
- package/dist/tools/python-repl/paths.d.ts +84 -0
- package/dist/tools/python-repl/paths.d.ts.map +1 -0
- package/dist/tools/python-repl/paths.js +213 -0
- package/dist/tools/python-repl/paths.js.map +1 -0
- package/dist/tools/python-repl/session-lock.d.ts +111 -0
- package/dist/tools/python-repl/session-lock.d.ts.map +1 -0
- package/dist/tools/python-repl/session-lock.js +510 -0
- package/dist/tools/python-repl/session-lock.js.map +1 -0
- package/dist/tools/python-repl/socket-client.d.ts +42 -0
- package/dist/tools/python-repl/socket-client.d.ts.map +1 -0
- package/dist/tools/python-repl/socket-client.js +157 -0
- package/dist/tools/python-repl/socket-client.js.map +1 -0
- package/dist/tools/python-repl/tool.d.ts +100 -0
- package/dist/tools/python-repl/tool.d.ts.map +1 -0
- package/dist/tools/python-repl/tool.js +575 -0
- package/dist/tools/python-repl/tool.js.map +1 -0
- package/dist/tools/python-repl/types.d.ts +95 -0
- package/dist/tools/python-repl/types.d.ts.map +1 -0
- package/dist/tools/python-repl/types.js +2 -0
- package/dist/tools/python-repl/types.js.map +1 -0
- package/package.json +2 -1
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Gyoshu Python Bridge - JSON-RPC 2.0 over Unix Socket.
|
|
3
|
+
|
|
4
|
+
This bridge provides a protocol-based interface for executing Python code
|
|
5
|
+
from the Scientist agent. Communication happens over Unix socket using
|
|
6
|
+
Newline-Delimited JSON (NDJSON) with JSON-RPC 2.0 message format.
|
|
7
|
+
|
|
8
|
+
Protocol Format (JSON-RPC 2.0):
|
|
9
|
+
Request: {"jsonrpc": "2.0", "id": "req_001", "method": "execute", "params": {...}}
|
|
10
|
+
Response: {"jsonrpc": "2.0", "id": "req_001", "result": {...}}
|
|
11
|
+
Error: {"jsonrpc": "2.0", "id": "req_001", "error": {"code": -32600, "message": "..."}}
|
|
12
|
+
|
|
13
|
+
Methods:
|
|
14
|
+
- execute(code, timeout) - Execute Python code in persistent namespace
|
|
15
|
+
- interrupt() - Set interrupt flag for running execution
|
|
16
|
+
- reset() - Clear execution namespace
|
|
17
|
+
- get_state() - Get memory and variable info
|
|
18
|
+
- ping() - Health check
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import sys
|
|
22
|
+
import os
|
|
23
|
+
import json
|
|
24
|
+
import time
|
|
25
|
+
import io
|
|
26
|
+
import re
|
|
27
|
+
import signal
|
|
28
|
+
import contextlib
|
|
29
|
+
import traceback
|
|
30
|
+
import threading
|
|
31
|
+
import gc
|
|
32
|
+
import argparse
|
|
33
|
+
import socket as socket_module
|
|
34
|
+
import stat
|
|
35
|
+
from datetime import datetime, timezone
|
|
36
|
+
from typing import Any, Dict, List, Optional, Callable, Tuple
|
|
37
|
+
|
|
38
|
+
# =============================================================================
|
|
39
|
+
# JSON-RPC 2.0 PROTOCOL
|
|
40
|
+
# =============================================================================
|
|
41
|
+
|
|
42
|
+
JSON_RPC_VERSION = "2.0"
|
|
43
|
+
|
|
44
|
+
# JSON-RPC 2.0 Error Codes
|
|
45
|
+
ERROR_PARSE = -32700 # Invalid JSON
|
|
46
|
+
ERROR_INVALID_REQUEST = -32600 # Not a valid Request object
|
|
47
|
+
ERROR_METHOD_NOT_FOUND = -32601 # Method does not exist
|
|
48
|
+
ERROR_INVALID_PARAMS = -32602 # Invalid method parameters
|
|
49
|
+
ERROR_INTERNAL = -32603 # Internal JSON-RPC error
|
|
50
|
+
ERROR_EXECUTION = -32000 # Application-specific: execution error
|
|
51
|
+
ERROR_TIMEOUT = -32001 # Application-specific: timeout
|
|
52
|
+
|
|
53
|
+
# Global protocol output stream (set per-connection in socket mode)
|
|
54
|
+
_protocol_out: Optional[io.TextIOWrapper] = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _send_protocol(data: dict) -> None:
|
|
58
|
+
"""Write NDJSON message to protocol channel."""
|
|
59
|
+
global _protocol_out
|
|
60
|
+
if _protocol_out:
|
|
61
|
+
_protocol_out.write(
|
|
62
|
+
json.dumps(data, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
63
|
+
)
|
|
64
|
+
_protocol_out.flush()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def send_response(
|
|
68
|
+
id: Optional[str], result: Optional[Dict] = None, error: Optional[Dict] = None
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Send JSON-RPC 2.0 response via protocol channel."""
|
|
71
|
+
response: Dict[str, Any] = {
|
|
72
|
+
"jsonrpc": JSON_RPC_VERSION,
|
|
73
|
+
"id": id,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if error is not None:
|
|
77
|
+
response["error"] = error
|
|
78
|
+
else:
|
|
79
|
+
response["result"] = result
|
|
80
|
+
|
|
81
|
+
_send_protocol(response)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def make_error(code: int, message: str, data: Optional[Any] = None) -> Dict:
|
|
85
|
+
"""Create a JSON-RPC 2.0 error object."""
|
|
86
|
+
error = {"code": code, "message": message}
|
|
87
|
+
if data is not None:
|
|
88
|
+
error["data"] = data
|
|
89
|
+
return error
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# =============================================================================
|
|
93
|
+
# MARKER PARSING
|
|
94
|
+
# =============================================================================
|
|
95
|
+
|
|
96
|
+
# Marker pattern for structured output
|
|
97
|
+
# Examples:
|
|
98
|
+
# [OBJECTIVE] Loading data...
|
|
99
|
+
# [STAT:mean] 0.95
|
|
100
|
+
# [DATA] Shape: (100, 5)
|
|
101
|
+
MARKER_REGEX = re.compile(
|
|
102
|
+
r"^\s*\[([A-Z][A-Z0-9_-]*)(?::([^\]]+))?\]\s*(.*)$", re.MULTILINE
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Scientific marker taxonomy
|
|
106
|
+
MARKER_CATEGORIES = {
|
|
107
|
+
# Research Process
|
|
108
|
+
"OBJECTIVE": "research_process",
|
|
109
|
+
"HYPOTHESIS": "research_process",
|
|
110
|
+
"EXPERIMENT": "research_process",
|
|
111
|
+
"OBSERVATION": "research_process",
|
|
112
|
+
"ANALYSIS": "research_process",
|
|
113
|
+
"CONCLUSION": "research_process",
|
|
114
|
+
# Data Operations
|
|
115
|
+
"DATA": "data_operations",
|
|
116
|
+
"SHAPE": "data_operations",
|
|
117
|
+
"DTYPE": "data_operations",
|
|
118
|
+
"RANGE": "data_operations",
|
|
119
|
+
"MISSING": "data_operations",
|
|
120
|
+
"MEMORY": "data_operations",
|
|
121
|
+
# Calculations
|
|
122
|
+
"CALC": "calculations",
|
|
123
|
+
"METRIC": "calculations",
|
|
124
|
+
"STAT": "calculations",
|
|
125
|
+
"CORR": "calculations",
|
|
126
|
+
# Artifacts
|
|
127
|
+
"PLOT": "artifacts",
|
|
128
|
+
"ARTIFACT": "artifacts",
|
|
129
|
+
"TABLE": "artifacts",
|
|
130
|
+
"FIGURE": "artifacts",
|
|
131
|
+
# Insights
|
|
132
|
+
"FINDING": "insights",
|
|
133
|
+
"INSIGHT": "insights",
|
|
134
|
+
"PATTERN": "insights",
|
|
135
|
+
# Workflow
|
|
136
|
+
"STEP": "workflow",
|
|
137
|
+
"STAGE": "workflow",
|
|
138
|
+
"CHECKPOINT": "workflow",
|
|
139
|
+
"CHECK": "workflow",
|
|
140
|
+
"INFO": "workflow",
|
|
141
|
+
"WARNING": "workflow",
|
|
142
|
+
"ERROR": "workflow",
|
|
143
|
+
"DEBUG": "workflow",
|
|
144
|
+
# Scientific
|
|
145
|
+
"CITATION": "scientific",
|
|
146
|
+
"LIMITATION": "scientific",
|
|
147
|
+
"NEXT_STEP": "scientific",
|
|
148
|
+
"DECISION": "scientific",
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def parse_markers(text: str) -> List[Dict[str, Any]]:
|
|
153
|
+
"""Extract markers from output text.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
text: Raw output text potentially containing markers
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
List of marker dicts with type, subtype, content, line_number, category, valid
|
|
160
|
+
"""
|
|
161
|
+
markers = []
|
|
162
|
+
|
|
163
|
+
for match in MARKER_REGEX.finditer(text):
|
|
164
|
+
raw_type = match.group(1)
|
|
165
|
+
marker_type = raw_type.replace("-", "_")
|
|
166
|
+
subtype_str = match.group(2) # May be None
|
|
167
|
+
content = match.group(3).strip()
|
|
168
|
+
|
|
169
|
+
# Calculate line number (1-indexed)
|
|
170
|
+
line_number = text[: match.start()].count("\n") + 1
|
|
171
|
+
|
|
172
|
+
# Classify marker and check validity
|
|
173
|
+
category = MARKER_CATEGORIES.get(marker_type, "unknown")
|
|
174
|
+
valid = marker_type in MARKER_CATEGORIES
|
|
175
|
+
|
|
176
|
+
markers.append(
|
|
177
|
+
{
|
|
178
|
+
"type": marker_type,
|
|
179
|
+
"subtype": subtype_str,
|
|
180
|
+
"content": content,
|
|
181
|
+
"line_number": line_number,
|
|
182
|
+
"category": category,
|
|
183
|
+
"valid": valid,
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
return markers
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# =============================================================================
|
|
191
|
+
# BOUNDED STRING IO
|
|
192
|
+
# =============================================================================
|
|
193
|
+
|
|
194
|
+
MAX_CAPTURE_CHARS = 1048576 # 1MB default
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class BoundedStringIO:
|
|
198
|
+
"""StringIO wrapper that caps capture size to prevent memory exhaustion."""
|
|
199
|
+
|
|
200
|
+
def __init__(self, max_size: int = MAX_CAPTURE_CHARS):
|
|
201
|
+
self._buffer: List[str] = []
|
|
202
|
+
self._size = 0
|
|
203
|
+
self._max_size = max_size
|
|
204
|
+
self._truncated = False
|
|
205
|
+
|
|
206
|
+
def write(self, s: str) -> int:
|
|
207
|
+
if self._truncated:
|
|
208
|
+
return len(s)
|
|
209
|
+
new_size = self._size + len(s)
|
|
210
|
+
if new_size > self._max_size:
|
|
211
|
+
remaining = self._max_size - self._size
|
|
212
|
+
if remaining > 0:
|
|
213
|
+
self._buffer.append(s[:remaining])
|
|
214
|
+
self._truncated = True
|
|
215
|
+
else:
|
|
216
|
+
self._buffer.append(s)
|
|
217
|
+
self._size = new_size
|
|
218
|
+
return len(s)
|
|
219
|
+
|
|
220
|
+
def getvalue(self) -> str:
|
|
221
|
+
result = "".join(self._buffer)
|
|
222
|
+
if self._truncated:
|
|
223
|
+
result += "\n[OUTPUT TRUNCATED - exceeded 1MB limit]"
|
|
224
|
+
return result
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def truncated(self) -> bool:
|
|
228
|
+
return self._truncated
|
|
229
|
+
|
|
230
|
+
def flush(self) -> None:
|
|
231
|
+
"""No-op for compatibility with sys.stdout interface."""
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# =============================================================================
|
|
236
|
+
# MEMORY UTILITIES
|
|
237
|
+
# =============================================================================
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def get_memory_usage() -> Dict[str, float]:
|
|
241
|
+
"""Get current process memory usage in MB.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Dict with rss_mb (resident set size) and vms_mb (virtual memory size)
|
|
245
|
+
"""
|
|
246
|
+
try:
|
|
247
|
+
import psutil
|
|
248
|
+
|
|
249
|
+
process = psutil.Process()
|
|
250
|
+
mem = process.memory_info()
|
|
251
|
+
return {
|
|
252
|
+
"rss_mb": round(mem.rss / (1024 * 1024), 2),
|
|
253
|
+
"vms_mb": round(mem.vms / (1024 * 1024), 2),
|
|
254
|
+
}
|
|
255
|
+
except ImportError:
|
|
256
|
+
# Fallback: use resource module
|
|
257
|
+
try:
|
|
258
|
+
import resource
|
|
259
|
+
|
|
260
|
+
usage = resource.getrusage(resource.RUSAGE_SELF)
|
|
261
|
+
# maxrss is in KB on Linux, bytes on macOS
|
|
262
|
+
rss_kb = usage.ru_maxrss
|
|
263
|
+
if sys.platform == "darwin":
|
|
264
|
+
rss_kb = rss_kb / 1024 # Convert bytes to KB on macOS
|
|
265
|
+
return {
|
|
266
|
+
"rss_mb": round(rss_kb / 1024, 2),
|
|
267
|
+
"vms_mb": 0.0, # Not available via resource
|
|
268
|
+
}
|
|
269
|
+
except ImportError:
|
|
270
|
+
# Final fallback: read from /proc on Linux
|
|
271
|
+
try:
|
|
272
|
+
with open(f"/proc/{os.getpid()}/status", "r") as f:
|
|
273
|
+
status = f.read()
|
|
274
|
+
|
|
275
|
+
rss = 0.0
|
|
276
|
+
vms = 0.0
|
|
277
|
+
for line in status.split("\n"):
|
|
278
|
+
if line.startswith("VmRSS:"):
|
|
279
|
+
rss = int(line.split()[1]) / 1024 # kB to MB
|
|
280
|
+
elif line.startswith("VmSize:"):
|
|
281
|
+
vms = int(line.split()[1]) / 1024
|
|
282
|
+
|
|
283
|
+
return {"rss_mb": round(rss, 2), "vms_mb": round(vms, 2)}
|
|
284
|
+
except Exception:
|
|
285
|
+
return {"rss_mb": 0.0, "vms_mb": 0.0}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def clean_memory() -> Dict[str, float]:
|
|
289
|
+
"""Run garbage collection and return memory after cleanup."""
|
|
290
|
+
gc.collect()
|
|
291
|
+
return get_memory_usage()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# =============================================================================
|
|
295
|
+
# EXECUTION STATE
|
|
296
|
+
# =============================================================================
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class ExecutionState:
|
|
300
|
+
"""Manages persistent execution namespace and interrupt handling."""
|
|
301
|
+
|
|
302
|
+
def __init__(self):
|
|
303
|
+
self._namespace: Dict[str, Any] = {}
|
|
304
|
+
self._interrupt_flag = threading.Event()
|
|
305
|
+
self._execution_lock = threading.Lock()
|
|
306
|
+
|
|
307
|
+
# Initialize with common imports available
|
|
308
|
+
self._initialize_namespace()
|
|
309
|
+
|
|
310
|
+
def _initialize_namespace(self):
|
|
311
|
+
"""Set up default namespace with helper functions."""
|
|
312
|
+
self._namespace = {
|
|
313
|
+
"__name__": "__gyoshu__",
|
|
314
|
+
"__doc__": "Gyoshu execution namespace",
|
|
315
|
+
# Provide helper functions
|
|
316
|
+
"clean_memory": clean_memory,
|
|
317
|
+
"get_memory": get_memory_usage,
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
def reset(self) -> Dict[str, Any]:
|
|
321
|
+
"""Clear namespace and reset state."""
|
|
322
|
+
with self._execution_lock:
|
|
323
|
+
self._namespace.clear()
|
|
324
|
+
self._initialize_namespace()
|
|
325
|
+
self._interrupt_flag.clear()
|
|
326
|
+
gc.collect()
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
"status": "reset",
|
|
330
|
+
"memory": get_memory_usage(),
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
def get_state(self) -> Dict[str, Any]:
|
|
334
|
+
"""Return current state information."""
|
|
335
|
+
# Get user-defined variables (exclude dunder and builtins)
|
|
336
|
+
user_vars = [
|
|
337
|
+
k
|
|
338
|
+
for k in self._namespace.keys()
|
|
339
|
+
if not k.startswith("_") and k not in ("clean_memory", "get_memory")
|
|
340
|
+
]
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
"memory": get_memory_usage(),
|
|
344
|
+
"variables": user_vars,
|
|
345
|
+
"variable_count": len(user_vars),
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
def interrupt(self) -> Dict[str, Any]:
|
|
349
|
+
"""Set interrupt flag to stop execution."""
|
|
350
|
+
self._interrupt_flag.set()
|
|
351
|
+
return {"status": "interrupt_requested"}
|
|
352
|
+
|
|
353
|
+
@property
|
|
354
|
+
def namespace(self) -> Dict[str, Any]:
|
|
355
|
+
return self._namespace
|
|
356
|
+
|
|
357
|
+
@property
|
|
358
|
+
def interrupt_flag(self) -> threading.Event:
|
|
359
|
+
return self._interrupt_flag
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# Global execution state
|
|
363
|
+
_state = ExecutionState()
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# =============================================================================
|
|
367
|
+
# CODE EXECUTION
|
|
368
|
+
# =============================================================================
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class ExecutionTimeoutError(Exception):
|
|
372
|
+
"""Raised when code execution exceeds timeout."""
|
|
373
|
+
|
|
374
|
+
pass
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _timeout_handler(signum, frame):
|
|
378
|
+
"""Signal handler for execution timeout."""
|
|
379
|
+
raise ExecutionTimeoutError("Code execution timed out")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def execute_code(
|
|
383
|
+
code: str,
|
|
384
|
+
namespace: Dict[str, Any],
|
|
385
|
+
timeout: Optional[float] = None,
|
|
386
|
+
interrupt_flag: Optional[threading.Event] = None,
|
|
387
|
+
) -> Dict[str, Any]:
|
|
388
|
+
"""Execute Python code and capture output.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
code: Python code to execute
|
|
392
|
+
namespace: Execution namespace (modified in place)
|
|
393
|
+
timeout: Maximum execution time in seconds (None = no limit)
|
|
394
|
+
interrupt_flag: Event to check for interrupt requests
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
Dict with success, stdout, stderr, exception info
|
|
398
|
+
"""
|
|
399
|
+
stdout_capture = BoundedStringIO()
|
|
400
|
+
stderr_capture = BoundedStringIO()
|
|
401
|
+
|
|
402
|
+
result = {
|
|
403
|
+
"success": False,
|
|
404
|
+
"stdout": "",
|
|
405
|
+
"stderr": "",
|
|
406
|
+
"stdout_truncated": False,
|
|
407
|
+
"stderr_truncated": False,
|
|
408
|
+
"exception": None,
|
|
409
|
+
"exception_type": None,
|
|
410
|
+
"traceback": None,
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
# Set up timeout (Unix only - uses SIGALRM)
|
|
414
|
+
old_handler = None
|
|
415
|
+
if timeout and hasattr(signal, "SIGALRM"):
|
|
416
|
+
old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
|
|
417
|
+
signal.alarm(int(timeout))
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
# Redirect stdout/stderr for user code
|
|
421
|
+
with contextlib.redirect_stdout(stdout_capture), contextlib.redirect_stderr(
|
|
422
|
+
stderr_capture
|
|
423
|
+
):
|
|
424
|
+
# Compile code for better error messages
|
|
425
|
+
compiled = compile(code, "<gyoshu>", "exec")
|
|
426
|
+
|
|
427
|
+
# Execute in provided namespace
|
|
428
|
+
exec(compiled, namespace)
|
|
429
|
+
|
|
430
|
+
result["success"] = True
|
|
431
|
+
|
|
432
|
+
except ExecutionTimeoutError as e:
|
|
433
|
+
result["exception"] = str(e)
|
|
434
|
+
result["exception_type"] = "TimeoutError"
|
|
435
|
+
result["traceback"] = "Execution timed out"
|
|
436
|
+
|
|
437
|
+
except KeyboardInterrupt:
|
|
438
|
+
result["exception"] = "Execution interrupted"
|
|
439
|
+
result["exception_type"] = "KeyboardInterrupt"
|
|
440
|
+
result["traceback"] = "Interrupted by user"
|
|
441
|
+
|
|
442
|
+
except SyntaxError as e:
|
|
443
|
+
result["exception"] = str(e)
|
|
444
|
+
result["exception_type"] = "SyntaxError"
|
|
445
|
+
result["traceback"] = "".join(
|
|
446
|
+
traceback.format_exception(type(e), e, e.__traceback__)
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
except Exception as e:
|
|
450
|
+
result["exception"] = str(e)
|
|
451
|
+
result["exception_type"] = type(e).__name__
|
|
452
|
+
result["traceback"] = "".join(
|
|
453
|
+
traceback.format_exception(type(e), e, e.__traceback__)
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
finally:
|
|
457
|
+
if timeout and hasattr(signal, "SIGALRM"):
|
|
458
|
+
signal.alarm(0)
|
|
459
|
+
if old_handler is not None:
|
|
460
|
+
signal.signal(signal.SIGALRM, old_handler)
|
|
461
|
+
|
|
462
|
+
result["stdout"] = stdout_capture.getvalue()
|
|
463
|
+
result["stderr"] = stderr_capture.getvalue()
|
|
464
|
+
result["stdout_truncated"] = stdout_capture.truncated
|
|
465
|
+
result["stderr_truncated"] = stderr_capture.truncated
|
|
466
|
+
|
|
467
|
+
return result
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# =============================================================================
|
|
471
|
+
# REQUEST HANDLERS
|
|
472
|
+
# =============================================================================
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def handle_execute(id: str, params: Dict[str, Any]) -> None:
|
|
476
|
+
"""Handle 'execute' method - run Python code.
|
|
477
|
+
|
|
478
|
+
Params:
|
|
479
|
+
code (str): Python code to execute
|
|
480
|
+
timeout (float, optional): Timeout in seconds (default: 300)
|
|
481
|
+
"""
|
|
482
|
+
code = params.get("code")
|
|
483
|
+
if not code:
|
|
484
|
+
send_response(
|
|
485
|
+
id,
|
|
486
|
+
error=make_error(ERROR_INVALID_PARAMS, "Missing required parameter: code"),
|
|
487
|
+
)
|
|
488
|
+
return
|
|
489
|
+
|
|
490
|
+
if not isinstance(code, str):
|
|
491
|
+
send_response(
|
|
492
|
+
id,
|
|
493
|
+
error=make_error(ERROR_INVALID_PARAMS, "Parameter 'code' must be a string"),
|
|
494
|
+
)
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
timeout = params.get("timeout", 300) # Default 5 minutes
|
|
498
|
+
if not isinstance(timeout, (int, float)) or timeout <= 0:
|
|
499
|
+
timeout = 300
|
|
500
|
+
|
|
501
|
+
# Clear interrupt flag before execution
|
|
502
|
+
_state.interrupt_flag.clear()
|
|
503
|
+
|
|
504
|
+
# Record start time
|
|
505
|
+
start_time = time.time()
|
|
506
|
+
started_at = datetime.now(timezone.utc).isoformat()
|
|
507
|
+
|
|
508
|
+
# Execute the code
|
|
509
|
+
exec_result = execute_code(
|
|
510
|
+
code=code,
|
|
511
|
+
namespace=_state.namespace,
|
|
512
|
+
timeout=timeout,
|
|
513
|
+
interrupt_flag=_state.interrupt_flag,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
# Calculate duration
|
|
517
|
+
duration_ms = round((time.time() - start_time) * 1000, 2)
|
|
518
|
+
|
|
519
|
+
# Parse markers from stdout
|
|
520
|
+
markers = parse_markers(exec_result["stdout"])
|
|
521
|
+
|
|
522
|
+
# Build response
|
|
523
|
+
response = {
|
|
524
|
+
"success": exec_result["success"],
|
|
525
|
+
"stdout": exec_result["stdout"],
|
|
526
|
+
"stderr": exec_result["stderr"],
|
|
527
|
+
"stdout_truncated": exec_result.get("stdout_truncated", False),
|
|
528
|
+
"stderr_truncated": exec_result.get("stderr_truncated", False),
|
|
529
|
+
"markers": markers,
|
|
530
|
+
"timing": {
|
|
531
|
+
"started_at": started_at,
|
|
532
|
+
"duration_ms": duration_ms,
|
|
533
|
+
},
|
|
534
|
+
"memory": get_memory_usage(),
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
# Add error info if failed
|
|
538
|
+
if not exec_result["success"]:
|
|
539
|
+
response["error"] = {
|
|
540
|
+
"type": exec_result["exception_type"],
|
|
541
|
+
"message": exec_result["exception"],
|
|
542
|
+
"traceback": exec_result["traceback"],
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
send_response(id, result=response)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def handle_interrupt(id: str, params: Dict[str, Any]) -> None:
|
|
549
|
+
"""Handle 'interrupt' method - signal interrupt to running code."""
|
|
550
|
+
result = _state.interrupt()
|
|
551
|
+
send_response(id, result=result)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def handle_reset(id: str, params: Dict[str, Any]) -> None:
|
|
555
|
+
"""Handle 'reset' method - clear namespace and state."""
|
|
556
|
+
result = _state.reset()
|
|
557
|
+
send_response(id, result=result)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def handle_get_state(id: str, params: Dict[str, Any]) -> None:
|
|
561
|
+
"""Handle 'get_state' method - return current state info."""
|
|
562
|
+
result = _state.get_state()
|
|
563
|
+
send_response(id, result=result)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def handle_ping(id: str, params: Dict[str, Any]) -> None:
|
|
567
|
+
"""Handle 'ping' method - health check."""
|
|
568
|
+
send_response(
|
|
569
|
+
id,
|
|
570
|
+
result={
|
|
571
|
+
"status": "ok",
|
|
572
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
573
|
+
},
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
# Method registry
|
|
578
|
+
HANDLERS: Dict[str, Callable[[str, Dict[str, Any]], None]] = {
|
|
579
|
+
"execute": handle_execute,
|
|
580
|
+
"interrupt": handle_interrupt,
|
|
581
|
+
"reset": handle_reset,
|
|
582
|
+
"get_state": handle_get_state,
|
|
583
|
+
"ping": handle_ping,
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
# =============================================================================
|
|
588
|
+
# REQUEST PROCESSING
|
|
589
|
+
# =============================================================================
|
|
590
|
+
|
|
591
|
+
# Cap JSON-RPC request line size to prevent DoS (10MB)
|
|
592
|
+
MAX_REQUEST_LINE_BYTES = 10 * 1024 * 1024
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def read_bounded_line(stream, max_bytes: int) -> Tuple[Optional[bytes], bool]:
|
|
596
|
+
"""Read a line with bounded byte count.
|
|
597
|
+
|
|
598
|
+
Returns:
|
|
599
|
+
Tuple of (line_bytes or None if EOF, was_oversized)
|
|
600
|
+
- If EOF with no data: (None, False)
|
|
601
|
+
- If line fits in limit: (bytes, False)
|
|
602
|
+
- If line exceeded limit: (truncated_bytes, True)
|
|
603
|
+
"""
|
|
604
|
+
data = bytearray()
|
|
605
|
+
while len(data) < max_bytes:
|
|
606
|
+
char = stream.read(1)
|
|
607
|
+
if not char:
|
|
608
|
+
# EOF - return what we have
|
|
609
|
+
return (bytes(data) if data else None, False)
|
|
610
|
+
if char == b"\n":
|
|
611
|
+
# Normal line termination
|
|
612
|
+
return (bytes(data), False)
|
|
613
|
+
data.extend(char)
|
|
614
|
+
|
|
615
|
+
# Limit exceeded - drain rest of line
|
|
616
|
+
while True:
|
|
617
|
+
char = stream.read(1)
|
|
618
|
+
if not char or char == b"\n":
|
|
619
|
+
break
|
|
620
|
+
return (bytes(data[:max_bytes]), True)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def process_request(line: str) -> None:
|
|
624
|
+
"""Parse and handle a single JSON-RPC request."""
|
|
625
|
+
request_id: Optional[str] = None
|
|
626
|
+
|
|
627
|
+
try:
|
|
628
|
+
# Parse JSON
|
|
629
|
+
try:
|
|
630
|
+
request = json.loads(line)
|
|
631
|
+
except json.JSONDecodeError as e:
|
|
632
|
+
send_response(None, error=make_error(ERROR_PARSE, f"Parse error: {e}"))
|
|
633
|
+
return
|
|
634
|
+
|
|
635
|
+
# Validate request structure
|
|
636
|
+
if not isinstance(request, dict):
|
|
637
|
+
send_response(
|
|
638
|
+
None,
|
|
639
|
+
error=make_error(
|
|
640
|
+
ERROR_INVALID_REQUEST, "Request must be a JSON object"
|
|
641
|
+
),
|
|
642
|
+
)
|
|
643
|
+
return
|
|
644
|
+
|
|
645
|
+
# Extract id (may be null for notifications, but we require it)
|
|
646
|
+
request_id = request.get("id")
|
|
647
|
+
|
|
648
|
+
# Check jsonrpc version
|
|
649
|
+
if request.get("jsonrpc") != JSON_RPC_VERSION:
|
|
650
|
+
send_response(
|
|
651
|
+
request_id,
|
|
652
|
+
error=make_error(
|
|
653
|
+
ERROR_INVALID_REQUEST,
|
|
654
|
+
f"Invalid jsonrpc version, expected '{JSON_RPC_VERSION}'",
|
|
655
|
+
),
|
|
656
|
+
)
|
|
657
|
+
return
|
|
658
|
+
|
|
659
|
+
# Extract method
|
|
660
|
+
method = request.get("method")
|
|
661
|
+
if not method or not isinstance(method, str):
|
|
662
|
+
send_response(
|
|
663
|
+
request_id,
|
|
664
|
+
error=make_error(ERROR_INVALID_REQUEST, "Missing or invalid 'method'"),
|
|
665
|
+
)
|
|
666
|
+
return
|
|
667
|
+
|
|
668
|
+
# Extract params (optional, default to empty dict)
|
|
669
|
+
params = request.get("params", {})
|
|
670
|
+
if not isinstance(params, dict):
|
|
671
|
+
send_response(
|
|
672
|
+
request_id,
|
|
673
|
+
error=make_error(
|
|
674
|
+
ERROR_INVALID_PARAMS, "Parameter 'params' must be an object"
|
|
675
|
+
),
|
|
676
|
+
)
|
|
677
|
+
return
|
|
678
|
+
|
|
679
|
+
# Find handler
|
|
680
|
+
handler = HANDLERS.get(method)
|
|
681
|
+
if not handler:
|
|
682
|
+
send_response(
|
|
683
|
+
request_id,
|
|
684
|
+
error=make_error(ERROR_METHOD_NOT_FOUND, f"Method not found: {method}"),
|
|
685
|
+
)
|
|
686
|
+
return
|
|
687
|
+
|
|
688
|
+
# Execute handler
|
|
689
|
+
handler(request_id, params)
|
|
690
|
+
|
|
691
|
+
except Exception as e:
|
|
692
|
+
# Catch-all for unexpected errors
|
|
693
|
+
send_response(
|
|
694
|
+
request_id,
|
|
695
|
+
error=make_error(
|
|
696
|
+
ERROR_INTERNAL, f"Internal error: {e}", data=traceback.format_exc()
|
|
697
|
+
),
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
# =============================================================================
|
|
702
|
+
# SOCKET SERVER
|
|
703
|
+
# =============================================================================
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def safe_unlink_socket(socket_path: str) -> None:
|
|
707
|
+
"""Safely unlink a socket file, handling races and verifying type."""
|
|
708
|
+
try:
|
|
709
|
+
st = os.lstat(socket_path)
|
|
710
|
+
if stat.S_ISSOCK(st.st_mode):
|
|
711
|
+
os.unlink(socket_path)
|
|
712
|
+
except FileNotFoundError:
|
|
713
|
+
pass # Already removed
|
|
714
|
+
except OSError:
|
|
715
|
+
pass # Best effort
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def run_socket_server(socket_path: str) -> None:
|
|
719
|
+
"""Run the JSON-RPC server over Unix socket."""
|
|
720
|
+
global _protocol_out
|
|
721
|
+
|
|
722
|
+
# Safely remove existing socket
|
|
723
|
+
safe_unlink_socket(socket_path)
|
|
724
|
+
|
|
725
|
+
server = socket_module.socket(socket_module.AF_UNIX, socket_module.SOCK_STREAM)
|
|
726
|
+
|
|
727
|
+
# Set umask to ensure socket mode 0600 (owner only)
|
|
728
|
+
old_umask = os.umask(0o177)
|
|
729
|
+
try:
|
|
730
|
+
server.bind(socket_path)
|
|
731
|
+
|
|
732
|
+
# Post-bind verification: ensure socket has expected ownership and mode
|
|
733
|
+
try:
|
|
734
|
+
st = os.lstat(socket_path)
|
|
735
|
+
if not stat.S_ISSOCK(st.st_mode):
|
|
736
|
+
raise RuntimeError(
|
|
737
|
+
f"Post-bind check failed: {socket_path} is not a socket"
|
|
738
|
+
)
|
|
739
|
+
if st.st_uid != os.getuid():
|
|
740
|
+
raise RuntimeError(
|
|
741
|
+
f"Post-bind check failed: {socket_path} not owned by us"
|
|
742
|
+
)
|
|
743
|
+
mode = st.st_mode & 0o777
|
|
744
|
+
if mode != 0o600:
|
|
745
|
+
raise RuntimeError(
|
|
746
|
+
f"Post-bind check failed: {socket_path} has mode {oct(mode)}, expected 0o600"
|
|
747
|
+
)
|
|
748
|
+
except Exception:
|
|
749
|
+
server.close()
|
|
750
|
+
raise
|
|
751
|
+
finally:
|
|
752
|
+
os.umask(old_umask)
|
|
753
|
+
|
|
754
|
+
server.listen(1)
|
|
755
|
+
|
|
756
|
+
print(
|
|
757
|
+
f"[gyoshu_bridge] Socket server started at {socket_path}, PID={os.getpid()}",
|
|
758
|
+
file=sys.stderr,
|
|
759
|
+
)
|
|
760
|
+
sys.stderr.flush()
|
|
761
|
+
|
|
762
|
+
def shutdown_handler(signum, frame):
|
|
763
|
+
print("[gyoshu_bridge] Shutdown signal received", file=sys.stderr)
|
|
764
|
+
sys.stderr.flush()
|
|
765
|
+
server.close()
|
|
766
|
+
safe_unlink_socket(socket_path)
|
|
767
|
+
sys.exit(0)
|
|
768
|
+
|
|
769
|
+
signal.signal(signal.SIGTERM, shutdown_handler)
|
|
770
|
+
signal.signal(signal.SIGINT, shutdown_handler)
|
|
771
|
+
|
|
772
|
+
try:
|
|
773
|
+
while True:
|
|
774
|
+
conn, addr = server.accept()
|
|
775
|
+
handle_socket_connection(conn)
|
|
776
|
+
except Exception as e:
|
|
777
|
+
print(f"[gyoshu_bridge] Server error: {e}", file=sys.stderr)
|
|
778
|
+
traceback.print_exc(file=sys.stderr)
|
|
779
|
+
finally:
|
|
780
|
+
server.close()
|
|
781
|
+
safe_unlink_socket(socket_path)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def handle_socket_connection(conn: socket_module.socket) -> None:
|
|
785
|
+
"""Handle a single client connection."""
|
|
786
|
+
global _protocol_out
|
|
787
|
+
|
|
788
|
+
try:
|
|
789
|
+
_protocol_out = conn.makefile("w", buffering=1, encoding="utf-8")
|
|
790
|
+
|
|
791
|
+
reader = conn.makefile("rb")
|
|
792
|
+
while True:
|
|
793
|
+
line_bytes, was_oversized = read_bounded_line(
|
|
794
|
+
reader, MAX_REQUEST_LINE_BYTES
|
|
795
|
+
)
|
|
796
|
+
if line_bytes is None:
|
|
797
|
+
break
|
|
798
|
+
if was_oversized:
|
|
799
|
+
send_response(
|
|
800
|
+
None, error=make_error(ERROR_INVALID_REQUEST, "Request too large")
|
|
801
|
+
)
|
|
802
|
+
continue
|
|
803
|
+
line = line_bytes.decode("utf-8", errors="replace").strip()
|
|
804
|
+
if not line:
|
|
805
|
+
continue
|
|
806
|
+
process_request(line)
|
|
807
|
+
except Exception as e:
|
|
808
|
+
print(f"[gyoshu_bridge] Connection error: {e}", file=sys.stderr)
|
|
809
|
+
traceback.print_exc(file=sys.stderr)
|
|
810
|
+
finally:
|
|
811
|
+
try:
|
|
812
|
+
conn.close()
|
|
813
|
+
except Exception:
|
|
814
|
+
pass
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
# =============================================================================
|
|
818
|
+
# MAIN
|
|
819
|
+
# =============================================================================
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def parse_args() -> argparse.Namespace:
|
|
823
|
+
parser = argparse.ArgumentParser(
|
|
824
|
+
description="Gyoshu Python Bridge - JSON-RPC 2.0 over Unix Socket"
|
|
825
|
+
)
|
|
826
|
+
parser.add_argument(
|
|
827
|
+
"socket_path",
|
|
828
|
+
nargs="?",
|
|
829
|
+
help="Unix socket path (required)",
|
|
830
|
+
)
|
|
831
|
+
return parser.parse_args()
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def main() -> None:
|
|
835
|
+
args = parse_args()
|
|
836
|
+
|
|
837
|
+
if not args.socket_path:
|
|
838
|
+
print("Usage: gyoshu_bridge.py <socket_path>", file=sys.stderr)
|
|
839
|
+
print("Example: gyoshu_bridge.py /tmp/gyoshu.sock", file=sys.stderr)
|
|
840
|
+
sys.exit(1)
|
|
841
|
+
|
|
842
|
+
run_socket_server(args.socket_path)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
if __name__ == "__main__":
|
|
846
|
+
main()
|