* unsloth start: keep the local subagent unattended and out of plan mode The local subagent child could stall waiting on a permission prompt, and a parent session in plan mode could still reach the editing agent. - Drop human-blocking tools from the child so it runs unattended. The read-only child also drops the file writers. - Emit a PreToolUse hook that reads permission_mode itself and denies the editing agent under plan mode, so routing holds when the model ignores SKILL.md. Fails open, and is skipped under the WSL bridge where a Windows interpreter path is not runnable in the distro. * Make the read-only subagent actually read-only, and drop stale WSL gates From the first review of this branch, which drove the real code against a fake HOME holding a pre-existing Claude install and diffed the tree before and after. No config, agent, MCP server or CLAUDE.md of the user's was touched in either arm, and the session dir is removed on exit, Ctrl-C and exception. Three real findings came out of it: - The read-only child could still write. Plan mode routes Bash through a safety classifier served by the same local model, so a small model saying yes is what authorised the write; a child spawned with read_only created a file. Denying Bash there makes the label true, at the cost of shell exploration while planning. Read, Grep and Glob still cover the search it needs. - A persisted plugin dir kept a plan_gate.py from an earlier Windows run, so a later WSL run shipped a hooks.json naming an interpreter the distro cannot execute. Hook errors do not block, so this only ever wasted a spawn, but it accumulated and the branch had no test. - The comment claimed the read-only child keeps ExitPlanMode "as Claude does under plan mode". A --print child is never offered the plan or prompt tools at all, so most of both deny lists is inert today. Kept as a guard against a version that starts offering them, but the comment now says so. Also covers "auto" in the gate's non-plan modes, which is a real permission_mode and the one the child's own Bash classifier runs under. * Stop the gate failing closed, and bound a wedged child Second review of this branch, driving real claude 2.1.219 against a mock endpoint rather than reading. The gate could fail closed. If plan_gate.py went missing the interpreter exited 2, which Claude treats as a blocking hook error, so the editing tool was denied in every mode rather than just plan. Running the script through runpy instead of handing its path to the interpreter turns that into an ordinary traceback, which is exit 1 and allows. Verified both exit codes directly. The hook also had no timeout, so a hung one stalled the parent for as long as it hung, measured past 400s. Bounded at 10s. The real stall this branch is named for was untouched: run_local_agent polled communicate() forever, so a local server that accepts and never answers left the child and the parent blocked indefinitely, measured past 400s. Added a wall-clock deadline that kills the child and says the server looks wedged. UNSLOTH_CLAUDE_SUBAGENT_TIMEOUT overrides it, 0 restores the old behaviour. Also corrected the plan-mode comment. Claude already refuses the editing tool in plan mode on its own, since it advertises readOnlyHint false; what the hook adds is a reason naming the read-only tool to call instead. The WSL comment had the direction backwards: the gate is the Linux path, not the Windows one. Tests: the hook command's quoting and its behaviour with the gate deleted, both previously unguarded, plus the timeout and its env override. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep the gate path out of the shell string Codex review. The hook command is run by a shell, and the gate path was interpolated into it, so a session-config root containing shell metacharacters expanded before Python saw it. Verified on both: sh expands $(..), backticks and $VAR; cmd expands %VAR%. In every case the path no longer resolves, the gate exits 1, and because that intentionally fails open the routing message silently stops appearing. The path now travels as base64, whose alphabet has no metacharacter in either shell. Parametrised over all four hostile forms, and the old interpolation makes those tests fail. One correction to the report: it says the editing agent becomes callable in plan mode. It does not. Claude refuses that tool by itself, since it advertises readOnlyHint false, which was checked earlier by deleting the hook entirely. What a mangled path costs is the reason naming the read-only agent to call instead, not the block. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
469 lines
17 KiB
Python
469 lines
17 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Small stdio MCP bridge from cloud Claude Code to a local Claude Code child."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import signal
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from typing import Any, Callable
|
|
|
|
from unsloth_cli.commands.start import (
|
|
_CLAUDE_ENV_UNSET,
|
|
_SUBAGENT_DESCRIPTION,
|
|
_SUBAGENT_INSTRUCTIONS,
|
|
_SUBAGENT_PLAN_DESCRIPTION,
|
|
_SUBAGENT_PLAN_INSTRUCTIONS,
|
|
_claude_flags,
|
|
_claude_local_env,
|
|
_wsl_shim_env,
|
|
)
|
|
|
|
_MAX_RESULT_CHARACTERS = 100_000
|
|
_CANCEL_POLL_SECONDS = 0.1
|
|
_CANCEL_GRACE_SECONDS = 2.0
|
|
# A local server that accepts the connection and then never answers leaves the
|
|
# child, and the parent waiting on it, blocked forever. Generous enough not to cut
|
|
# a long legitimate run short; 0 restores the unbounded wait.
|
|
_DEFAULT_TIMEOUT_SECONDS = 1800.0
|
|
|
|
|
|
def _required_env(name: str) -> str:
|
|
value = os.environ.get(name, "").strip()
|
|
if not value:
|
|
raise RuntimeError(f"Missing {name}.")
|
|
return value
|
|
|
|
|
|
def _timeout_seconds() -> float:
|
|
"""Wall-clock cap on one child run; 0 or unparsable means wait forever."""
|
|
raw = os.environ.get("UNSLOTH_CLAUDE_SUBAGENT_TIMEOUT")
|
|
if raw is None or not raw.strip():
|
|
return _DEFAULT_TIMEOUT_SECONDS
|
|
try:
|
|
parsed = float(raw.strip())
|
|
except ValueError:
|
|
return _DEFAULT_TIMEOUT_SECONDS
|
|
return parsed if parsed > 0 else 0.0
|
|
|
|
|
|
def _bounded(text: str) -> str:
|
|
if len(text) <= _MAX_RESULT_CHARACTERS:
|
|
return text
|
|
return text[:_MAX_RESULT_CHARACTERS] + "\n\n[Local agent output truncated]"
|
|
|
|
|
|
def _result_text(stdout: str) -> str:
|
|
lines = [line for line in stdout.splitlines() if line.strip()]
|
|
candidates = [stdout.strip(), *reversed(lines)]
|
|
for candidate in candidates:
|
|
try:
|
|
payload = json.loads(candidate)
|
|
except ValueError:
|
|
continue
|
|
if not isinstance(payload, dict):
|
|
continue
|
|
result = payload.get("result")
|
|
if payload.get("is_error"):
|
|
raise RuntimeError(str(result or "The local Claude agent failed."))
|
|
if isinstance(result, str) and result.strip():
|
|
return _bounded(result.strip())
|
|
raise RuntimeError("The local Claude agent returned no readable result.")
|
|
|
|
|
|
def _stop_child(process: subprocess.Popen) -> None:
|
|
"""Stop the Claude child and any tool processes it started."""
|
|
if process.poll() is not None:
|
|
if os.name != "nt":
|
|
# Leader exited, but its tool processes may still be running.
|
|
try:
|
|
os.killpg(process.pid, signal.SIGTERM)
|
|
except OSError:
|
|
return
|
|
time.sleep(_CANCEL_GRACE_SECONDS)
|
|
try:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
except OSError:
|
|
pass
|
|
return
|
|
if os.name == "nt":
|
|
try:
|
|
completed = subprocess.run(
|
|
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
|
capture_output = True,
|
|
timeout = 15,
|
|
check = False,
|
|
)
|
|
except Exception:
|
|
completed = None
|
|
# A failed taskkill must not leave the child running through the grace wait.
|
|
if (completed is None or completed.returncode != 0) and process.poll() is None:
|
|
process.terminate()
|
|
else:
|
|
try:
|
|
os.killpg(process.pid, signal.SIGTERM)
|
|
except OSError:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout = _CANCEL_GRACE_SECONDS)
|
|
except subprocess.TimeoutExpired:
|
|
if os.name == "nt":
|
|
process.kill()
|
|
else:
|
|
try:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
except OSError:
|
|
process.kill()
|
|
process.wait()
|
|
else:
|
|
if os.name != "nt":
|
|
# Leader is gone; kill any surviving group members.
|
|
try:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def run_local_agent(
|
|
task: str,
|
|
cancel_event: threading.Event | None = None,
|
|
read_only: bool = False,
|
|
) -> str:
|
|
base = _required_env("UNSLOTH_CLAUDE_SUBAGENT_BASE_URL")
|
|
key = _required_env("UNSLOTH_CLAUDE_SUBAGENT_API_KEY")
|
|
model = _required_env("UNSLOTH_CLAUDE_SUBAGENT_MODEL")
|
|
window = int(os.environ.get("UNSLOTH_CLAUDE_SUBAGENT_CONTEXT_WINDOW", "0") or 0)
|
|
entry = {"id": model, "context_length": window}
|
|
local_env = _claude_local_env(base, key, entry)
|
|
child_env = dict(os.environ)
|
|
|
|
executable = shutil.which("claude")
|
|
if executable is None:
|
|
raise RuntimeError("`claude` is not installed or is not on PATH.")
|
|
cancel_event = cancel_event or threading.Event()
|
|
if cancel_event.is_set():
|
|
raise RuntimeError("The local Claude agent was cancelled.")
|
|
command = [
|
|
"claude",
|
|
"--model",
|
|
model,
|
|
*_claude_flags(model),
|
|
"--permission-mode",
|
|
(
|
|
"plan"
|
|
if read_only
|
|
else (
|
|
"bypassPermissions"
|
|
if os.environ.get("UNSLOTH_CLAUDE_SUBAGENT_BYPASS_PERMISSIONS") == "1"
|
|
else "acceptEdits"
|
|
)
|
|
),
|
|
"--print",
|
|
"--output-format",
|
|
"json",
|
|
"--no-session-persistence",
|
|
# Strip human-blocking tools so the child runs unattended. Only the read-only
|
|
# child's writers bite today, since a --print child is never offered the plan
|
|
# or prompt tools; those are listed anyway so a version that starts offering
|
|
# them cannot stall the subagent. Bash is denied read-only side because plan
|
|
# mode gates it through the same local model, which is not a write barrier.
|
|
"--disallowedTools",
|
|
(
|
|
"AskUserQuestion,EnterPlanMode,Edit,Write,NotebookEdit,Bash"
|
|
if read_only
|
|
else "AskUserQuestion,EnterPlanMode,ExitPlanMode"
|
|
),
|
|
"--append-system-prompt",
|
|
_SUBAGENT_PLAN_INSTRUCTIONS if read_only else _SUBAGENT_INSTRUCTIONS,
|
|
f"Task: {task}",
|
|
]
|
|
bridged, wsl_names = _wsl_shim_env(command, local_env, _CLAUDE_ENV_UNSET)
|
|
if wsl_names:
|
|
from unsloth_cli.commands.start import _merge_wslenv
|
|
|
|
bridged = {**bridged, "PWD": os.getcwd()}
|
|
child_env["WSLENV"] = _merge_wslenv(child_env.get("WSLENV", ""), wsl_names)
|
|
for name in _CLAUDE_ENV_UNSET:
|
|
child_env[name] = ""
|
|
else:
|
|
for name in _CLAUDE_ENV_UNSET:
|
|
child_env.pop(name, None)
|
|
child_env.update(bridged)
|
|
popen_kwargs: dict[str, Any] = {
|
|
"cwd": os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd(),
|
|
"env": child_env,
|
|
"stdin": subprocess.DEVNULL,
|
|
"stdout": subprocess.PIPE,
|
|
"stderr": subprocess.PIPE,
|
|
"text": True,
|
|
}
|
|
if os.name == "nt":
|
|
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
else:
|
|
popen_kwargs["start_new_session"] = True
|
|
process = subprocess.Popen(
|
|
[executable, *command[1:]],
|
|
**popen_kwargs,
|
|
)
|
|
deadline = _timeout_seconds()
|
|
started_at = time.monotonic()
|
|
try:
|
|
while True:
|
|
try:
|
|
stdout, stderr = process.communicate(timeout = _CANCEL_POLL_SECONDS)
|
|
break
|
|
except subprocess.TimeoutExpired:
|
|
if cancel_event.is_set():
|
|
_stop_child(process)
|
|
raise RuntimeError("The local Claude agent was cancelled.")
|
|
waited = time.monotonic() - started_at
|
|
if deadline and waited > deadline:
|
|
_stop_child(process)
|
|
raise RuntimeError(
|
|
f"The local Claude agent produced nothing after {waited:.0f}s. "
|
|
"The local server is likely wedged; check that a model is loaded."
|
|
)
|
|
except BaseException:
|
|
if process.poll() is None:
|
|
_stop_child(process)
|
|
raise
|
|
if process.returncode != 0:
|
|
detail = stderr.strip() or stdout.strip()
|
|
raise RuntimeError(
|
|
_bounded(detail) or f"Local Claude exited with code {process.returncode}."
|
|
)
|
|
return _result_text(stdout)
|
|
|
|
|
|
def _response(
|
|
request: dict,
|
|
run_agent: Callable[[str], str] = run_local_agent,
|
|
tool_name: str = "unsloth_agent",
|
|
tool_description: str | None = None,
|
|
run_read_only_agent: Callable[[str], str] | None = None,
|
|
read_only_tool_name: str | None = None,
|
|
instructions: str | None = None,
|
|
) -> dict | None:
|
|
request_id = request.get("id")
|
|
method = request.get("method")
|
|
if request_id is None:
|
|
return None
|
|
if method == "initialize":
|
|
protocol = (request.get("params") or {}).get("protocolVersion") or "2025-06-18"
|
|
result = {
|
|
"protocolVersion": protocol,
|
|
"capabilities": {"tools": {"listChanged": False}},
|
|
"serverInfo": {"name": "unsloth-local-agent", "version": "1.0.0"},
|
|
}
|
|
if instructions:
|
|
result["instructions"] = instructions
|
|
elif method == "ping":
|
|
result = {}
|
|
elif method == "tools/list":
|
|
|
|
def tool_definition(name: str, description: str, read_only: bool) -> dict:
|
|
return {
|
|
"name": name,
|
|
"title": "Unsloth local plan agent" if read_only else "Unsloth local agent",
|
|
"description": description,
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"task": {
|
|
"type": "string",
|
|
"description": "The complete task for the local Unsloth agent.",
|
|
}
|
|
},
|
|
"required": ["task"],
|
|
"additionalProperties": False,
|
|
},
|
|
"annotations": {
|
|
"readOnlyHint": read_only,
|
|
"destructiveHint": not read_only,
|
|
"idempotentHint": read_only,
|
|
"openWorldHint": True,
|
|
},
|
|
"_meta": {"anthropic/maxResultSizeChars": _MAX_RESULT_CHARACTERS},
|
|
}
|
|
|
|
tools = [tool_definition(tool_name, tool_description or _SUBAGENT_DESCRIPTION, False)]
|
|
if read_only_tool_name and run_read_only_agent:
|
|
tools.append(tool_definition(read_only_tool_name, _SUBAGENT_PLAN_DESCRIPTION, True))
|
|
result = {"tools": tools}
|
|
elif method == "tools/call":
|
|
params = request.get("params") or {}
|
|
arguments = params.get("arguments") or {}
|
|
requested_tool = params.get("name")
|
|
selected_agent = (
|
|
run_agent
|
|
if requested_tool == tool_name
|
|
else (
|
|
run_read_only_agent
|
|
if requested_tool == read_only_tool_name and run_read_only_agent
|
|
else None
|
|
)
|
|
)
|
|
task = arguments.get("task") if selected_agent else None
|
|
if not isinstance(task, str) or not task.strip():
|
|
result = {
|
|
"content": [{"type": "text", "text": "A non-empty task is required."}],
|
|
"isError": True,
|
|
}
|
|
else:
|
|
try:
|
|
text = selected_agent(task.strip())
|
|
result = {"content": [{"type": "text", "text": text}], "isError": False}
|
|
except Exception as exc:
|
|
result = {
|
|
"content": [{"type": "text", "text": str(exc)}],
|
|
"isError": True,
|
|
}
|
|
else:
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": request_id,
|
|
"error": {"code": -32601, "message": f"Method not found: {method}"},
|
|
}
|
|
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
|
|
|
|
def serve(
|
|
stdin: Any = sys.stdin,
|
|
stdout: Any = sys.stdout,
|
|
run_agent: Callable[[str, threading.Event], str] = run_local_agent,
|
|
tool_name: str = "unsloth_agent",
|
|
tool_description: str | None = None,
|
|
run_read_only_agent: Callable[[str, threading.Event], str] | None = None,
|
|
read_only_tool_name: str | None = None,
|
|
instructions: str | None = None,
|
|
) -> None:
|
|
active: dict[object, threading.Event] = {}
|
|
workers: list[threading.Thread] = []
|
|
state_lock = threading.RLock()
|
|
output_lock = threading.Lock()
|
|
shutdown_started = threading.Event()
|
|
|
|
def cancel_active() -> None:
|
|
with state_lock:
|
|
pending = list(active.values())
|
|
for cancel_event in pending:
|
|
cancel_event.set()
|
|
|
|
def handle_shutdown(_signum: int, _frame: Any) -> None:
|
|
# Claude Code sends SIGINT (possibly repeatedly) to cancel a tool call. Only
|
|
# the first unwinds stdin; later ones must not interrupt process-tree cleanup.
|
|
first_signal = not shutdown_started.is_set()
|
|
shutdown_started.set()
|
|
cancel_active()
|
|
if first_signal:
|
|
raise KeyboardInterrupt
|
|
|
|
previous_handlers: dict[int, Any] = {}
|
|
if threading.current_thread() is threading.main_thread():
|
|
for signum in (signal.SIGINT, signal.SIGTERM):
|
|
previous_handlers[signum] = signal.signal(signum, handle_shutdown)
|
|
|
|
def send(response: dict | None) -> None:
|
|
if response is None:
|
|
return
|
|
with output_lock:
|
|
stdout.write(json.dumps(response, separators = (",", ":")) + "\n")
|
|
stdout.flush()
|
|
|
|
def call_tool(request: dict, request_id: object, cancel_event: threading.Event) -> None:
|
|
try:
|
|
response = _response(
|
|
request,
|
|
run_agent = lambda task: run_agent(task, cancel_event),
|
|
tool_name = tool_name,
|
|
tool_description = tool_description,
|
|
run_read_only_agent = (
|
|
(lambda task: run_read_only_agent(task, cancel_event))
|
|
if run_read_only_agent
|
|
else None
|
|
),
|
|
read_only_tool_name = read_only_tool_name,
|
|
instructions = instructions,
|
|
)
|
|
if not cancel_event.is_set():
|
|
send(response)
|
|
finally:
|
|
with state_lock:
|
|
if active.get(request_id) is cancel_event:
|
|
active.pop(request_id, None)
|
|
|
|
try:
|
|
for line in stdin:
|
|
try:
|
|
request = json.loads(line)
|
|
if not isinstance(request, dict):
|
|
response = None
|
|
elif request.get("method") == "notifications/cancelled":
|
|
request_id = (request.get("params") or {}).get("requestId")
|
|
with state_lock:
|
|
cancel_event = active.get(request_id)
|
|
if cancel_event is not None:
|
|
cancel_event.set()
|
|
response = None
|
|
elif request.get("method") == "tools/call" and request.get("id") is not None:
|
|
request_id = request["id"]
|
|
cancel_event = threading.Event()
|
|
with state_lock:
|
|
active[request_id] = cancel_event
|
|
worker = threading.Thread(
|
|
target = call_tool,
|
|
args = (request, request_id, cancel_event),
|
|
name = f"unsloth-agent-{request_id}",
|
|
)
|
|
workers.append(worker)
|
|
worker.start()
|
|
response = None
|
|
else:
|
|
response = _response(
|
|
request,
|
|
tool_name = tool_name,
|
|
tool_description = tool_description,
|
|
run_read_only_agent = (
|
|
(lambda task: run_read_only_agent(task, threading.Event()))
|
|
if run_read_only_agent
|
|
else None
|
|
),
|
|
read_only_tool_name = read_only_tool_name,
|
|
instructions = instructions,
|
|
)
|
|
except Exception as exc:
|
|
response = {
|
|
"jsonrpc": "2.0",
|
|
"id": None,
|
|
"error": {"code": -32603, "message": str(exc)},
|
|
}
|
|
send(response)
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
cancel_active()
|
|
for worker in workers:
|
|
if worker.ident is not None:
|
|
worker.join()
|
|
for signum, handler in previous_handlers.items():
|
|
signal.signal(signum, handler)
|
|
|
|
|
|
def main() -> None:
|
|
serve(
|
|
run_read_only_agent = lambda task, cancel_event: run_local_agent(
|
|
task, cancel_event, read_only = True
|
|
),
|
|
read_only_tool_name = "unsloth_plan_agent",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|