clauder 0.1.0__tar.gz

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.
@@ -0,0 +1,16 @@
1
+ .DS_Store
2
+ *.mov
3
+ .Rproj.user
4
+ ClaudeR.Rproj
5
+ .Rhistory
6
+
7
+ # Credentials — never commit
8
+ .mcpregistry_*
9
+
10
+ # Build artifacts
11
+ clauder-mcp/dist/
12
+ __pycache__/
13
+ *.pyc
14
+
15
+ # R plot artifacts
16
+ Rplots.pdf
clauder-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,30 @@
1
+ Metadata-Version: 2.5
2
+ Name: clauder
3
+ Version: 0.1.0
4
+ Summary: Connect AI agents to a live Python session (Positron, Jupyter, VS Code, or a bare REPL) over the ClaudeR protocol
5
+ Author: Nykko Vitali
6
+ License: MIT
7
+ Keywords: data-science,jupyter,llm,mcp,positron
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+
11
+ # clauder
12
+
13
+ Expose a live Python session to MCP-based AI agents. The Python half of
14
+ [ClaudeR](https://github.com/IMNMV/ClaudeR).
15
+
16
+ ```python
17
+ import clauder
18
+ clauder.start()
19
+ ```
20
+
21
+ The agent then executes code in this session, where your loaded data and
22
+ fitted models already are, and sees the figures it draws. Every line it runs
23
+ is logged and attributed. Works in Positron, Jupyter, VS Code, and a bare
24
+ REPL.
25
+
26
+ ```python
27
+ clauder.start(session_name = "analysis", port = 8790)
28
+ clauder.status()
29
+ clauder.stop()
30
+ ```
@@ -0,0 +1,20 @@
1
+ # clauder
2
+
3
+ Expose a live Python session to MCP-based AI agents. The Python half of
4
+ [ClaudeR](https://github.com/IMNMV/ClaudeR).
5
+
6
+ ```python
7
+ import clauder
8
+ clauder.start()
9
+ ```
10
+
11
+ The agent then executes code in this session, where your loaded data and
12
+ fitted models already are, and sees the figures it draws. Every line it runs
13
+ is logged and attributed. Works in Positron, Jupyter, VS Code, and a bare
14
+ REPL.
15
+
16
+ ```python
17
+ clauder.start(session_name = "analysis", port = 8790)
18
+ clauder.status()
19
+ clauder.stop()
20
+ ```
@@ -0,0 +1,14 @@
1
+ [project]
2
+ name = "clauder"
3
+ version = "0.1.0"
4
+ description = "Connect AI agents to a live Python session (Positron, Jupyter, VS Code, or a bare REPL) over the ClaudeR protocol"
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ authors = [{ name = "Nykko Vitali" }]
8
+ keywords = ["mcp", "llm", "positron", "jupyter", "data-science"]
9
+ license = { text = "MIT" }
10
+ dependencies = []
11
+
12
+ [build-system]
13
+ requires = ["hatchling"]
14
+ build-backend = "hatchling.build"
@@ -0,0 +1,13 @@
1
+ """clauder: expose a live Python session to MCP agents.
2
+
3
+ import clauder
4
+ clauder.start()
5
+
6
+ Speaks the same protocol as the ClaudeR R addin, so one MCP bridge reaches
7
+ both an R session and a Python session, and an agent picks either by name.
8
+ """
9
+
10
+ from .server import start, status, stop
11
+
12
+ __version__ = "0.1.0"
13
+ __all__ = ["start", "stop", "status"]
@@ -0,0 +1,166 @@
1
+ """Discovery records, byte-compatible with the R side.
2
+
3
+ Both languages write into ~/.claude_r_sessions so one bridge can see every
4
+ session regardless of which one it is. The record shape, the lock protocol and
5
+ the atomic replace all match R/ui.R deliberately. The only addition is the
6
+ `language` field, which is absent in records written by R before this existed
7
+ and therefore means "r" when missing.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ import sys
16
+ import tempfile
17
+ import time
18
+ from typing import Any, Dict, Optional
19
+
20
+ # Frozen shared contract. Do not rename these.
21
+ DISCOVERY_DIRNAME = ".claude_r_sessions"
22
+ TOKEN_HEADER = "X-Clauder-Token"
23
+
24
+ _BAD_NAME = re.compile(r'[\\/:<>"|?*\x00-\x1f]')
25
+
26
+
27
+ def home_dir() -> str:
28
+ if sys.platform == "win32":
29
+ return os.environ.get("USERPROFILE") or os.path.expanduser("~")
30
+ return os.path.expanduser("~")
31
+
32
+
33
+ def discovery_dir() -> str:
34
+ return os.path.join(home_dir(), DISCOVERY_DIRNAME)
35
+
36
+
37
+ def discovery_path(session_name: str) -> str:
38
+ if (not session_name or session_name in (".", "..")
39
+ or _BAD_NAME.search(session_name)):
40
+ raise ValueError("Invalid discovery session name")
41
+ return os.path.join(discovery_dir(), session_name + ".json")
42
+
43
+
44
+ def pid_is_alive(pid: Any) -> Optional[bool]:
45
+ """True, False, or None for "cannot tell".
46
+
47
+ None matters. A permission error must not be read as permission to delete
48
+ another session's record, which is why this is tri-state like the R side.
49
+ """
50
+ try:
51
+ pid = int(pid)
52
+ except (TypeError, ValueError):
53
+ return None
54
+ if pid <= 0:
55
+ return None
56
+ if sys.platform == "win32":
57
+ try:
58
+ import ctypes
59
+ SYNCHRONIZE = 0x00100000
60
+ h = ctypes.windll.kernel32.OpenProcess(SYNCHRONIZE, False, pid)
61
+ if h:
62
+ ctypes.windll.kernel32.CloseHandle(h)
63
+ return True
64
+ return False if ctypes.windll.kernel32.GetLastError() == 87 else None
65
+ except Exception:
66
+ return None
67
+ try:
68
+ os.kill(pid, 0)
69
+ return True
70
+ except ProcessLookupError:
71
+ return False
72
+ except PermissionError:
73
+ return True # alive, owned by someone else
74
+ except Exception:
75
+ return None
76
+
77
+
78
+ class _Lock:
79
+ """Directory lock, the same mechanism R uses, so the two interoperate."""
80
+
81
+ def __init__(self, path: str):
82
+ self.lock = path + ".lock"
83
+
84
+ def __enter__(self):
85
+ try:
86
+ os.mkdir(self.lock, 0o700)
87
+ except FileExistsError:
88
+ raise RuntimeError(
89
+ "Discovery record is locked; do not remove another writer's lock"
90
+ ) from None
91
+ return self
92
+
93
+ def __exit__(self, *exc):
94
+ try:
95
+ os.rmdir(self.lock)
96
+ except OSError:
97
+ pass
98
+ return False
99
+
100
+
101
+ def write_record(session_name: str, port: int, token: str,
102
+ plot_auto: bool = True, tool_sets: Any = "all") -> str:
103
+ d = discovery_dir()
104
+ os.makedirs(d, mode=0o700, exist_ok=True)
105
+ f = discovery_path(session_name)
106
+ with _Lock(f):
107
+ other = None
108
+ if os.path.exists(f):
109
+ try:
110
+ with open(f) as fh:
111
+ other = json.load(fh)
112
+ except Exception as e:
113
+ raise RuntimeError(
114
+ "Unreadable discovery record at %s (%s). Inspect or remove "
115
+ "it, then start the server again." % (f, e)
116
+ ) from None
117
+ if other is not None and not isinstance(other, dict):
118
+ raise RuntimeError(
119
+ "Unreadable discovery identity in %s; explicit inspection required" % f)
120
+ if other is not None and int(other.get("pid", -1)) != os.getpid() \
121
+ and pid_is_alive(other.get("pid")) is not False:
122
+ raise RuntimeError(
123
+ "Session name '%s' is already owned by a live or unknown process"
124
+ % session_name)
125
+
126
+ same_owner = (other is not None
127
+ and int(other.get("pid", -1)) == os.getpid()
128
+ and other.get("token") == token)
129
+ started = (other.get("started_at") if same_owner and other.get("started_at")
130
+ else time.strftime("%Y-%m-%dT%H:%M:%S"))
131
+
132
+ info: Dict[str, Any] = {
133
+ "session_name": session_name,
134
+ "port": int(port),
135
+ "pid": os.getpid(),
136
+ "token": token,
137
+ "started_at": started,
138
+ "plot_auto": bool(plot_auto),
139
+ "tool_sets": tool_sets,
140
+ "language": "python",
141
+ }
142
+ fd, tmp = tempfile.mkstemp(prefix=".discovery-", dir=d)
143
+ try:
144
+ with os.fdopen(fd, "w") as fh:
145
+ json.dump(info, fh, indent=2)
146
+ os.chmod(tmp, 0o600)
147
+ os.replace(tmp, f) # atomic, including on Windows
148
+ finally:
149
+ if os.path.exists(tmp):
150
+ os.unlink(tmp)
151
+ return f
152
+
153
+
154
+ def remove_record(session_name: str) -> None:
155
+ """Delete our own record only. Never another process's."""
156
+ f = discovery_path(session_name)
157
+ if not os.path.exists(f):
158
+ return
159
+ with _Lock(f):
160
+ try:
161
+ with open(f) as fh:
162
+ info = json.load(fh)
163
+ except Exception:
164
+ return
165
+ if isinstance(info, dict) and int(info.get("pid", -1)) == os.getpid():
166
+ os.unlink(f)
@@ -0,0 +1,420 @@
1
+ """The HTTP server that runs inside the user's Python session.
2
+
3
+ Speaks the same wire protocol as the R addin, so the existing bridge needs no
4
+ new transport. POST {code, agent_id, want_plot} and get back
5
+ {success, output, plot, error}. GET returns a status object.
6
+
7
+ Execution goes through IPython's run_cell when a shell is present, so output,
8
+ display hooks and the IDE's plot pane behave exactly as if the user had typed
9
+ the code. Outside IPython it falls back to exec against a persistent namespace.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import io
16
+ import json
17
+ import os
18
+ import secrets
19
+ import threading
20
+ import time
21
+ import traceback
22
+ from http.server import BaseHTTPRequestHandler, HTTPServer
23
+ from typing import Any, Dict, List, Optional
24
+
25
+ from .discovery import TOKEN_HEADER, remove_record, write_record
26
+
27
+ MAX_OUTPUT_CHARS = 100_000
28
+
29
+ # Frozen log markers, matching R so one log file holds both sides and the
30
+ # existing parsers keep working.
31
+ _LOG_HEADER = "# --- [%s] ---\n# Code executed by %s:\n%s\n\n"
32
+ _LOG_OUTPUT = "#> "
33
+
34
+
35
+ class _State:
36
+ def __init__(self) -> None:
37
+ self.httpd: Optional[HTTPServer] = None
38
+ self.thread: Optional[threading.Thread] = None
39
+ self.session_name: str = "default"
40
+ self.port: Optional[int] = None
41
+ self.token: Optional[str] = None
42
+ self.require_token: bool = False
43
+ self.plot_auto: bool = True
44
+ self.log_path: Optional[str] = None
45
+ self.print_to_console: bool = True
46
+ self.execution_count: int = 0
47
+ self.agents: List[str] = []
48
+ self.namespace: Dict[str, Any] = {}
49
+ # R is single threaded and serialises on its event loop. Python can be
50
+ # re-entered from the server thread, so serialise here instead.
51
+ self.exec_lock = threading.Lock()
52
+
53
+
54
+ STATE = _State()
55
+
56
+
57
+ def _shell():
58
+ try:
59
+ from IPython import get_ipython
60
+ return get_ipython()
61
+ except Exception:
62
+ return None
63
+
64
+
65
+ def _log(code: str, agent: str) -> None:
66
+ """Write the code entry. Called before execution, so a hang still leaves
67
+ a record of what was running."""
68
+ if not STATE.log_path:
69
+ return
70
+ try:
71
+ with open(STATE.log_path, "a") as fh:
72
+ fh.write(_LOG_HEADER % (time.strftime("%Y-%m-%d %H:%M:%S"), agent, code))
73
+ except Exception:
74
+ pass
75
+
76
+
77
+ def _log_output(output: str) -> None:
78
+ """Append what the code printed, under the entry just written for it.
79
+ The "#> " prefix marks these lines as output rather than code, which is
80
+ what the R-side log parsers already skip."""
81
+ if not STATE.log_path or not output.strip():
82
+ return
83
+ try:
84
+ lines = output.rstrip("\n").split("\n")
85
+ if len(lines) > 40:
86
+ lines = lines[:40] + ["... %d more lines not logged" % (len(lines) - 40)]
87
+ with open(STATE.log_path, "a") as fh:
88
+ fh.write("".join(_LOG_OUTPUT + l + "\n" for l in lines) + "\n")
89
+ except Exception:
90
+ pass
91
+
92
+
93
+ class _Tee(io.TextIOBase):
94
+ """Capture a copy while the user still sees their own output.
95
+
96
+ This is the Python counterpart of sink(split = TRUE) on the R side.
97
+ """
98
+
99
+ def __init__(self, real):
100
+ self.real = real
101
+ self.buf = io.StringIO()
102
+
103
+ def write(self, s):
104
+ try:
105
+ self.real.write(s)
106
+ except Exception:
107
+ pass
108
+ return self.buf.write(s)
109
+
110
+ def flush(self):
111
+ try:
112
+ self.real.flush()
113
+ except Exception:
114
+ pass
115
+
116
+
117
+ def _figure_png(fignum: Optional[int] = None) -> Optional[str]:
118
+ """PNG of the figure the executed code produced.
119
+
120
+ Rendering must go through Agg. An IDE backend may carry no raster
121
+ renderer of its own, and then a plain savefig quietly produces a broken
122
+ image rather than failing: measured against Positron's
123
+ FigureCanvasPositron, savefig gave 2098 bytes with the title missing and
124
+ labels clipped, where Agg gave 12504 correct ones. savefig(backend="agg")
125
+ renders through Agg for this one call and leaves fig.canvas alone, so the
126
+ figure still belongs to the IDE and still shows in its plot pane.
127
+
128
+ Two things that look like fixes and are not. bbox_inches="tight" computes
129
+ a degenerate box on a figure the IDE has not drawn yet, and
130
+ canvas.draw() on such a canvas leaves it unrenderable. Rendering a
131
+ pickled copy loses data outright: a four-bar chart came back as one bar
132
+ on a default 0-to-1 axis.
133
+ """
134
+ try:
135
+ import matplotlib.pyplot as plt
136
+ except Exception:
137
+ return None
138
+ nums = plt.get_fignums()
139
+ if not nums:
140
+ return None
141
+ target = fignum if fignum in nums else nums[-1]
142
+ try:
143
+ fig = plt.figure(target)
144
+ except Exception:
145
+ return None
146
+
147
+ data = b""
148
+ try:
149
+ buf = io.BytesIO()
150
+ fig.savefig(buf, format="png", backend="agg")
151
+ data = buf.getvalue()
152
+ except (TypeError, ValueError):
153
+ # The backend keyword arrived in matplotlib 3.4. Older versions get
154
+ # a plain savefig, which is correct wherever the canvas can raster.
155
+ try:
156
+ buf = io.BytesIO()
157
+ fig.savefig(buf, format="png")
158
+ data = buf.getvalue()
159
+ except Exception:
160
+ return None
161
+ except Exception:
162
+ return None
163
+
164
+ # A rendered figure is never this small. Refuse rather than hand back a
165
+ # blank picture that reads as a real result.
166
+ if len(data) < 1000:
167
+ return None
168
+ return base64.b64encode(data).decode()
169
+
170
+
171
+ def _run(code: str, agent_id: Optional[str], want_plot: bool) -> Dict[str, Any]:
172
+ import sys as _sys
173
+
174
+ agent = agent_id or "agent"
175
+ if agent not in STATE.agents:
176
+ STATE.agents.append(agent)
177
+
178
+ if STATE.print_to_console:
179
+ try:
180
+ print("\n### LLM [%s] executing the following code ###\n%s\n"
181
+ "### End of LLM code ###\n" % (agent, code))
182
+ except Exception:
183
+ pass
184
+
185
+ _log(code, agent)
186
+
187
+ with STATE.exec_lock:
188
+ try:
189
+ import matplotlib.pyplot as _plt
190
+ before = set(_plt.get_fignums())
191
+ except Exception:
192
+ before = set()
193
+
194
+ tee_out, tee_err = _Tee(_sys.stdout), _Tee(_sys.stderr)
195
+ real_out, real_err = _sys.stdout, _sys.stderr
196
+ _sys.stdout, _sys.stderr = tee_out, tee_err
197
+ error = None
198
+ try:
199
+ sh = _shell()
200
+ if sh is not None:
201
+ res = sh.run_cell(code, store_history=False, silent=False)
202
+ if getattr(res, "error_in_exec", None) is not None:
203
+ error = "".join(traceback.format_exception_only(
204
+ type(res.error_in_exec), res.error_in_exec)).strip()
205
+ elif getattr(res, "error_before_exec", None) is not None:
206
+ error = str(res.error_before_exec)
207
+ else:
208
+ exec(compile(code, "<clauder>", "exec"), STATE.namespace)
209
+ except Exception as e:
210
+ error = "".join(traceback.format_exception_only(type(e), e)).strip()
211
+ finally:
212
+ _sys.stdout, _sys.stderr = real_out, real_err
213
+
214
+ output = tee_out.buf.getvalue() + tee_err.buf.getvalue()
215
+ if len(output) > MAX_OUTPUT_CHARS:
216
+ output = output[:MAX_OUTPUT_CHARS] + "\n... output truncated"
217
+
218
+ try:
219
+ import matplotlib.pyplot as _plt
220
+ after = set(_plt.get_fignums())
221
+ except Exception:
222
+ after = set()
223
+ new_figs = after - before
224
+ drew = bool(new_figs) or (bool(after) and bool(before) and want_plot)
225
+ # The user may already have figures open, so identify ours rather than
226
+ # assuming the highest number is the one this call drew.
227
+ target_fig = max(new_figs) if new_figs else (max(after) if after else None)
228
+
229
+ STATE.execution_count += 1
230
+ resp: Dict[str, Any] = {"success": error is None, "output": output}
231
+ _log_output(output if error is None else (output + "\n" + error).strip())
232
+ if error is not None:
233
+ resp["error"] = error
234
+
235
+ if drew:
236
+ # want_plot is the with_plot tool asking outright, and always wins.
237
+ if want_plot or STATE.plot_auto:
238
+ png = _figure_png(target_fig)
239
+ if png:
240
+ resp["plot"] = {"data": png, "mime_type": "image/png"}
241
+ else:
242
+ resp["plot_available"] = True
243
+ resp["output"] = (resp["output"] + ("\n" if resp["output"] else "")
244
+ + "[a plot was drawn; ask for it with the plot tool]")
245
+ return resp
246
+
247
+
248
+ class _Handler(BaseHTTPRequestHandler):
249
+ protocol_version = "HTTP/1.1"
250
+
251
+ def log_message(self, *a): # keep the user's console clean
252
+ pass
253
+
254
+ def _send(self, obj: Dict[str, Any], status: int = 200) -> None:
255
+ body = json.dumps(obj).encode()
256
+ self.send_response(status)
257
+ self.send_header("Content-Type", "application/json")
258
+ self.send_header("Content-Length", str(len(body)))
259
+ self.end_headers()
260
+ self.wfile.write(body)
261
+
262
+ def _authorised(self) -> bool:
263
+ # Only browsers set Origin, and the bridge never does, so this closes
264
+ # the drive-by webpage vector at no compatibility cost.
265
+ if self.headers.get("Origin"):
266
+ self._send({"error": "Cross-origin requests are not allowed"}, 403)
267
+ return False
268
+ if STATE.require_token:
269
+ if self.headers.get(TOKEN_HEADER) != STATE.token:
270
+ self._send({"error": "Missing or invalid session token"}, 401)
271
+ return False
272
+ return True
273
+
274
+ def do_GET(self):
275
+ if not self._authorised():
276
+ return
277
+ self._send({
278
+ "running": True,
279
+ "language": "python",
280
+ "session_name": STATE.session_name,
281
+ "port": STATE.port,
282
+ "execution_count": STATE.execution_count,
283
+ "connected_agents": list(STATE.agents),
284
+ "plot_auto": STATE.plot_auto,
285
+ "log_to_file": bool(STATE.log_path),
286
+ "log_file_path": STATE.log_path or "",
287
+ })
288
+
289
+ def do_POST(self):
290
+ if not self._authorised():
291
+ return
292
+ try:
293
+ n = int(self.headers.get("Content-Length") or 0)
294
+ body = json.loads(self.rfile.read(n) or b"{}")
295
+ except Exception:
296
+ self._send({"error": "Invalid JSON in request body"}, 400)
297
+ return
298
+ code = body.get("code")
299
+ if not isinstance(code, str):
300
+ self._send({"success": False, "error": "No code supplied"}, 400)
301
+ return
302
+ try:
303
+ self._send(_run(code, body.get("agent_id"),
304
+ bool(body.get("want_plot"))))
305
+ except Exception as e:
306
+ self._send({"success": False, "error": "clauder internal error: %s" % e}, 500)
307
+
308
+
309
+ def start(session_name: str = "default", port: int = 8790,
310
+ require_token: bool = False, log_file: Optional[str] = None,
311
+ plot_auto: bool = True, print_to_console: bool = True,
312
+ quiet: bool = False) -> Dict[str, Any]:
313
+ """Expose this Python session to MCP agents.
314
+
315
+ Returns a dict describing the running server. Call stop() to shut it down.
316
+ """
317
+ requested_port = int(port)
318
+ if STATE.httpd is not None:
319
+ if not quiet:
320
+ print("clauder: already running on port %s as '%s'."
321
+ % (STATE.port, STATE.session_name))
322
+ return {"running": True, "port": STATE.port,
323
+ "session_name": STATE.session_name}
324
+
325
+ STATE.session_name = session_name
326
+ STATE.require_token = require_token
327
+ STATE.plot_auto = plot_auto
328
+ STATE.print_to_console = print_to_console
329
+ STATE.token = secrets.token_hex(32)
330
+ STATE.execution_count = 0
331
+ STATE.agents = []
332
+ STATE.log_path = log_file if log_file is not None else os.path.join(
333
+ home_dir_safe(), "clauder_python_log.py")
334
+
335
+ # Walk forward to the next free port rather than failing. A second session,
336
+ # or a server the OS has not finished releasing, should not stop you
337
+ # starting; being told which port you got is enough.
338
+ httpd = None
339
+ first_error = None
340
+ for candidate in range(int(port), int(port) + 20):
341
+ try:
342
+ httpd = HTTPServer(("127.0.0.1", candidate), _Handler)
343
+ port = candidate
344
+ break
345
+ except OSError as e:
346
+ if first_error is None:
347
+ first_error = e
348
+ if httpd is None:
349
+ raise RuntimeError(
350
+ "No free port in %d-%d (%s). Pass port= explicitly."
351
+ % (int(port), int(port) + 19, first_error)
352
+ ) from None
353
+
354
+ STATE.httpd = httpd
355
+ STATE.port = int(port)
356
+ try:
357
+ write_record(session_name, STATE.port, STATE.token,
358
+ plot_auto=plot_auto, tool_sets="all")
359
+ except Exception:
360
+ httpd.server_close()
361
+ STATE.httpd = None
362
+ STATE.port = None
363
+ raise
364
+
365
+ t = threading.Thread(target=httpd.serve_forever, daemon=True,
366
+ name="clauder-server")
367
+ t.start()
368
+ STATE.thread = t
369
+
370
+ if not quiet:
371
+ moved = " (requested port was busy)" if STATE.port != int(requested_port) else ""
372
+ print("clauder: listening on http://127.0.0.1:%d as session '%s'%s.\n"
373
+ "Agents can now reach this Python session. Call clauder.stop() "
374
+ "to end it." % (STATE.port, session_name, moved))
375
+ return {"running": True, "port": STATE.port, "session_name": session_name,
376
+ "language": "python", "token_required": require_token}
377
+
378
+
379
+ def home_dir_safe() -> str:
380
+ from .discovery import home_dir
381
+ return home_dir()
382
+
383
+
384
+ def stop(quiet: bool = False) -> None:
385
+ """Shut the server down and remove this session's discovery record."""
386
+ if STATE.httpd is None:
387
+ if not quiet:
388
+ print("clauder: not running.")
389
+ return
390
+ try:
391
+ STATE.httpd.shutdown()
392
+ except Exception:
393
+ pass
394
+ try:
395
+ STATE.httpd.server_close()
396
+ except Exception:
397
+ pass
398
+ try:
399
+ remove_record(STATE.session_name)
400
+ except Exception:
401
+ pass
402
+ STATE.httpd = None
403
+ STATE.thread = None
404
+ STATE.port = None
405
+ if not quiet:
406
+ print("clauder: stopped.")
407
+
408
+
409
+ def status() -> Dict[str, Any]:
410
+ return {
411
+ "running": STATE.httpd is not None,
412
+ "session_name": STATE.session_name,
413
+ "port": STATE.port,
414
+ "language": "python",
415
+ "execution_count": STATE.execution_count,
416
+ "connected_agents": list(STATE.agents),
417
+ "plot_auto": STATE.plot_auto,
418
+ "log_file": STATE.log_path,
419
+ "token_required": STATE.require_token,
420
+ }
@@ -0,0 +1,266 @@
1
+ """Tests for the Python session server.
2
+
3
+ Each test runs against a private discovery directory, never the user's real
4
+ ~/.claude_r_sessions, so a test run cannot disturb a live research session.
5
+ """
6
+
7
+ import base64
8
+ import json
9
+ import os
10
+ import sys
11
+ import tempfile
12
+ import unittest
13
+ import urllib.error
14
+ import urllib.request
15
+
16
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
17
+
18
+ from clauder import server as srv # noqa: E402
19
+ from clauder import discovery as disc # noqa: E402
20
+
21
+ PORT = 8894
22
+
23
+
24
+ class Base(unittest.TestCase):
25
+ def setUp(self):
26
+ self.dir = tempfile.mkdtemp()
27
+ self._real_home = disc.home_dir
28
+ disc.home_dir = lambda: self.dir
29
+ self.log = os.path.join(self.dir, "log.py")
30
+
31
+ def tearDown(self):
32
+ srv.stop(quiet=True)
33
+ disc.home_dir = self._real_home
34
+
35
+ def start(self, **kw):
36
+ kw.setdefault("session_name", "t")
37
+ kw.setdefault("port", PORT)
38
+ kw.setdefault("quiet", True)
39
+ kw.setdefault("print_to_console", False)
40
+ kw.setdefault("log_file", self.log)
41
+ return srv.start(**kw)
42
+
43
+ def post(self, code, want_plot=False, token=None, origin=None, agent="tester"):
44
+ body = {"code": code, "agent_id": agent}
45
+ if want_plot:
46
+ body["want_plot"] = True
47
+ h = {"Content-Type": "application/json"}
48
+ if token:
49
+ h[disc.TOKEN_HEADER] = token
50
+ if origin:
51
+ h["Origin"] = origin
52
+ r = urllib.request.Request("http://127.0.0.1:%d" % PORT,
53
+ json.dumps(body).encode(), h)
54
+ return json.load(urllib.request.urlopen(r, timeout=25))
55
+
56
+ def record(self):
57
+ with open(disc.discovery_path("t")) as fh:
58
+ return json.load(fh)
59
+
60
+
61
+ class DiscoveryTests(Base):
62
+ def test_record_declares_python_and_matches_r_shape(self):
63
+ self.start()
64
+ rec = self.record()
65
+ # The bridge reads these keys for every session regardless of language.
66
+ for k in ("session_name", "port", "pid", "token", "started_at",
67
+ "plot_auto", "tool_sets", "language"):
68
+ self.assertIn(k, rec)
69
+ self.assertEqual(rec["language"], "python")
70
+ self.assertEqual(rec["port"], PORT)
71
+ self.assertEqual(rec["pid"], os.getpid())
72
+
73
+ def test_record_is_private(self):
74
+ self.start()
75
+ mode = os.stat(disc.discovery_path("t")).st_mode & 0o777
76
+ self.assertEqual(mode, 0o600)
77
+
78
+ def test_stop_removes_only_our_record(self):
79
+ self.start()
80
+ p = disc.discovery_path("t")
81
+ self.assertTrue(os.path.exists(p))
82
+ srv.stop(quiet=True)
83
+ self.assertFalse(os.path.exists(p))
84
+
85
+ def test_refuses_name_held_by_another_live_process(self):
86
+ os.makedirs(disc.discovery_dir(), mode=0o700, exist_ok=True)
87
+ with open(disc.discovery_path("t"), "w") as fh:
88
+ json.dump({"session_name": "t", "port": 1, "pid": 1,
89
+ "token": "x", "started_at": "s"}, fh)
90
+ with self.assertRaises(RuntimeError):
91
+ self.start()
92
+
93
+ def test_pid_probe_is_tristate(self):
94
+ self.assertIs(disc.pid_is_alive(os.getpid()), True)
95
+ self.assertIs(disc.pid_is_alive(999999), False)
96
+ self.assertIsNone(disc.pid_is_alive("nonsense"))
97
+
98
+ def test_rejects_unsafe_session_names(self):
99
+ for bad in ("..", "a/b", "a\\b", "a:b", ""):
100
+ with self.assertRaises(ValueError):
101
+ disc.discovery_path(bad)
102
+
103
+
104
+ class ExecutionTests(Base):
105
+ def test_executes_and_persists_namespace(self):
106
+ self.start()
107
+ self.assertTrue(self.post("zz = 6 * 7")["success"])
108
+ r = self.post("print('zz is', zz)")
109
+ self.assertIn("zz is 42", r["output"])
110
+
111
+ def test_reports_errors_without_dying(self):
112
+ self.start()
113
+ r = self.post("1/0")
114
+ self.assertFalse(r["success"])
115
+ self.assertIn("ZeroDivisionError", r["error"])
116
+ self.assertTrue(self.post("1+1")["success"]) # still serving
117
+
118
+ def test_captures_stdout(self):
119
+ self.start()
120
+ r = self.post("for i in range(3): print('row', i)")
121
+ self.assertIn("row 0", r["output"])
122
+ self.assertIn("row 2", r["output"])
123
+
124
+ def test_logs_code_and_output_with_attribution(self):
125
+ self.start()
126
+ self.post("print('logged')", agent="agent-7")
127
+ text = open(self.log).read()
128
+ self.assertIn("# Code executed by agent-7:", text) # frozen R marker
129
+ self.assertIn("#> logged", text) # output marker
130
+ # The code must appear exactly once, not once per log call.
131
+ self.assertEqual(text.count("print('logged')"), 1)
132
+
133
+ def test_output_is_truncated(self):
134
+ # Lower the cap rather than printing a huge string: the tee echoes to
135
+ # the real console by design, and a 200k flood makes test output
136
+ # unreadable.
137
+ self.start()
138
+ original = srv.MAX_OUTPUT_CHARS
139
+ srv.MAX_OUTPUT_CHARS = 100
140
+ try:
141
+ r = self.post("print('x' * 500)")
142
+ self.assertLessEqual(len(r["output"]), 100 + 40)
143
+ self.assertIn("truncated", r["output"])
144
+ finally:
145
+ srv.MAX_OUTPUT_CHARS = original
146
+
147
+
148
+ class SecurityTests(Base):
149
+ def test_origin_header_is_refused(self):
150
+ self.start()
151
+ with self.assertRaises(urllib.error.HTTPError) as e:
152
+ self.post("1+1", origin="http://evil.test")
153
+ self.assertEqual(e.exception.code, 403)
154
+
155
+ def test_token_enforced_when_required(self):
156
+ self.start(require_token=True)
157
+ tok = self.record()["token"]
158
+ with self.assertRaises(urllib.error.HTTPError) as e:
159
+ self.post("1+1")
160
+ self.assertEqual(e.exception.code, 401)
161
+ self.assertTrue(self.post("1+1", token=tok)["success"])
162
+
163
+ def test_token_absent_by_default(self):
164
+ self.start()
165
+ self.assertTrue(self.post("1+1")["success"])
166
+
167
+
168
+ class PlotTests(Base):
169
+ def setUp(self):
170
+ super().setUp()
171
+ try:
172
+ import matplotlib
173
+ matplotlib.use("Agg")
174
+ except ImportError:
175
+ self.skipTest("matplotlib not installed")
176
+
177
+ def draw(self, **kw):
178
+ return self.post("import matplotlib.pyplot as plt\n"
179
+ "plt.figure(); plt.plot([1, 4, 9])", **kw)
180
+
181
+ def test_returns_png_when_a_figure_is_drawn(self):
182
+ self.start()
183
+ r = self.draw()
184
+ self.assertIn("plot", r)
185
+ self.assertEqual(r["plot"]["mime_type"], "image/png")
186
+ self.assertTrue(base64.b64decode(r["plot"]["data"]).startswith(b"\x89PNG"))
187
+
188
+ def test_no_plot_when_nothing_drawn(self):
189
+ self.start()
190
+ self.assertNotIn("plot", self.post("q = 1"))
191
+
192
+ def test_plot_auto_off_withholds_image_but_says_so(self):
193
+ self.start(plot_auto=False)
194
+ r = self.draw()
195
+ self.assertNotIn("plot", r)
196
+ self.assertTrue(r["plot_available"])
197
+ self.assertIn("plot was drawn", r["output"])
198
+
199
+ def test_want_plot_overrides_plot_auto(self):
200
+ self.start(plot_auto=False)
201
+ self.assertIn("plot", self.draw(want_plot=True))
202
+
203
+
204
+ class StatusTests(Base):
205
+ def test_get_reports_language_and_agents(self):
206
+ self.start()
207
+ self.post("1+1", agent="agent-a")
208
+ r = urllib.request.Request("http://127.0.0.1:%d" % PORT)
209
+ r.get_method = lambda: "GET"
210
+ info = json.load(urllib.request.urlopen(r, timeout=10))
211
+ self.assertEqual(info["language"], "python")
212
+ self.assertTrue(info["running"])
213
+ self.assertIn("agent-a", info["connected_agents"])
214
+
215
+ def test_status_helper(self):
216
+ self.start()
217
+ self.assertTrue(srv.status()["running"])
218
+ srv.stop(quiet=True)
219
+ self.assertFalse(srv.status()["running"])
220
+
221
+ def test_double_start_is_a_no_op(self):
222
+ a = self.start()
223
+ b = self.start()
224
+ self.assertEqual(a["port"], b["port"])
225
+
226
+ def test_busy_port_moves_to_the_next_free_one(self):
227
+ # Occupy the requested port from outside. Calling start() twice would
228
+ # hit the already-running guard and never reach the bind.
229
+ import socket
230
+ sock = socket.socket()
231
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
232
+ sock.bind(("127.0.0.1", PORT + 1))
233
+ sock.listen(1)
234
+ try:
235
+ info = srv.start(session_name="t", port=PORT + 1, quiet=True,
236
+ log_file=self.log)
237
+ self.assertNotEqual(info["port"], PORT + 1)
238
+ self.assertGreater(info["port"], PORT + 1)
239
+ # The record must carry the port actually bound, not the one asked
240
+ # for, or the bridge would post to the wrong place.
241
+ self.assertEqual(self.record()["port"], info["port"])
242
+ finally:
243
+ sock.close()
244
+
245
+ def test_gives_up_when_the_whole_range_is_busy(self):
246
+ import socket
247
+ base = PORT + 40
248
+ socks = []
249
+ try:
250
+ for i in range(20):
251
+ s_ = socket.socket()
252
+ s_.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
253
+ s_.bind(("127.0.0.1", base + i))
254
+ s_.listen(1)
255
+ socks.append(s_)
256
+ with self.assertRaises(RuntimeError) as e:
257
+ srv.start(session_name="t", port=base, quiet=True,
258
+ log_file=self.log)
259
+ self.assertIn("No free port", str(e.exception))
260
+ finally:
261
+ for s_ in socks:
262
+ s_.close()
263
+
264
+
265
+ if __name__ == "__main__":
266
+ unittest.main(verbosity=2)