Add devbox image Dockerfiles and build scripts, split from homelab-komodo
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# Exposes this workspace's Claude Code as a token-gated TCP listener so peer
|
||||
# workspaces can hand it a prompt and get back a real, autonomous run's final
|
||||
# result — the Claude Code equivalent of acp-bridge-listen.sh's kilo bridge.
|
||||
#
|
||||
# Unlike `kilo acp`, Claude Code has no stdio agent-protocol server of its
|
||||
# own to expose. The closest headless equivalent is `claude -p` (print
|
||||
# mode), which takes its prompt as an argv string rather than a JSON-RPC
|
||||
# stream and returns one clean `--output-format json` object on exit — no
|
||||
# ACP handshake, no per-call model/cwd negotiation needed. So instead of
|
||||
# socat EXEC'ing straight into the target binary (as acp-bridge-listen.sh
|
||||
# does for `kilo acp`), it EXEC's into claude-peer-runner.py, which reads
|
||||
# the prompt off the wire and invokes `claude -p` as a real argv (no shell
|
||||
# quoting to worry about).
|
||||
#
|
||||
# No second "info port" (contrast acp-bridge-listen.sh's port 4098) is
|
||||
# needed either: ACP's session/new takes an explicit `cwd` param the caller
|
||||
# must supply, but `claude -p` just runs wherever it's invoked, and this
|
||||
# script already `cd`s to PROJECT_DIR before starting socat — EXEC'd
|
||||
# children inherit that cwd.
|
||||
#
|
||||
# Reuses acp-bridge-auth.py unchanged for the shared-secret token gate
|
||||
# (PEER_BRIDGE_TOKEN) — it already just execs whatever command it's given
|
||||
# after the token line matches, nothing kilo-specific about it.
|
||||
#
|
||||
# Lives outside /home/coder — see vnc-startup.sh header comment for why.
|
||||
set -e
|
||||
|
||||
CLAUDE_PEER_PORT="${1:-4197}"
|
||||
PROJECT_DIR="${PROJECT_DIR:-/home/coder}"
|
||||
|
||||
mkdir -p ~/.local/log ~/.local/run
|
||||
|
||||
if [ -f ~/.local/run/claude-peer-bridge.pid ]; then
|
||||
kill "$(cat ~/.local/run/claude-peer-bridge.pid)" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
setsid nohup socat TCP-LISTEN:"$CLAUDE_PEER_PORT",fork,reuseaddr \
|
||||
EXEC:"python3 /usr/local/bin/acp-bridge-auth.py python3 /usr/local/bin/claude-peer-runner.py" \
|
||||
> ~/.local/log/claude-peer-bridge.log 2>&1 < /dev/null &
|
||||
echo $! > ~/.local/run/claude-peer-bridge.pid
|
||||
|
||||
echo "[ Claude peer bridge listening on :$CLAUDE_PEER_PORT ]"
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
# Sits between claude-peer-listen.sh's socat listener and headless Claude
|
||||
# Code. acp-bridge-auth.py has already consumed exactly the token line from
|
||||
# the connection before exec'ing into this script, so everything that
|
||||
# follows on stdin is the peer's prompt: a 4-byte big-endian length prefix,
|
||||
# then exactly that many bytes of prompt text.
|
||||
#
|
||||
# Confirmed live against real workspace containers: length-prefixing is
|
||||
# load-bearing, not a style choice. The original design read the prompt via
|
||||
# "read until EOF," with the client signaling end-of-prompt by
|
||||
# half-closing its write side (`shutdown(SHUT_WR)`). That half-close made
|
||||
# socat tear the connection down (or stop relaying the exec'd child's
|
||||
# output — not fully diagnosed which) the moment the child's response took
|
||||
# more than an instant to produce, independent of everything else: not
|
||||
# claude-specific, not about subprocess vs exec, not about which fds the
|
||||
# child inherited. A fast, purely-local response (no real work) slipped
|
||||
# out before whatever socat does on seeing that half-close; anything doing
|
||||
# real work (in Claude Code's case, an actual authenticated API call)
|
||||
# consistently got nothing back — confirmed by reproducing the identical
|
||||
# failure with a plain `sleep N; echo` script that never touched Claude
|
||||
# Code or this project's own scripts at all. A length prefix means the
|
||||
# reader knows exactly how many bytes to expect, so the client never needs
|
||||
# to half-close the connection while a response is still pending.
|
||||
#
|
||||
# The prompt is passed to `claude -p` as a single real argv element (never
|
||||
# through a shell), which is what avoids the quoting/injection hazard a
|
||||
# naive `EXEC:"claude -p $PROMPT"` would have. cwd is whatever
|
||||
# claude-peer-listen.sh already `cd`'d to before starting socat — Claude
|
||||
# Code has no per-invocation cwd param to pass the way ACP's session/new
|
||||
# does, it just runs wherever it's invoked.
|
||||
#
|
||||
# --mcp-config points at this workspace's own rendered MCP config (see
|
||||
# devenv/main.tf's claude_config rendering) so the peer's run gets the same
|
||||
# tools (context7, playwright, its own further peers, ...) a normal
|
||||
# in-workspace Claude Code session would — not a bare, tool-less run.
|
||||
#
|
||||
# ANTHROPIC_API_KEY is deliberately stripped from claude's environment
|
||||
# below. The container sets it for Kilo (routed through MiniMax's
|
||||
# Anthropic-compatible endpoint), but Claude Code's own auth strategy
|
||||
# checks ANTHROPIC_API_KEY *before* CLAUDE_CODE_OAUTH_TOKEN — so if it's
|
||||
# left inherited, Claude Code silently bills through that key instead of
|
||||
# using the subscription-plan OAuth token this deployment intends it to
|
||||
# use. See README's "Claude Code peer bridge" / auth section.
|
||||
#
|
||||
# Uses os.execvpe — a single in-place exec, same shape as acp-bridge-auth.py's
|
||||
# own exec into this script — rather than subprocess.run(), which hung
|
||||
# indefinitely as a second fork (claude as a grandchild of socat's
|
||||
# connection handler, rather than its direct EXEC target); plain binaries
|
||||
# (echo, cat) were unaffected by the same setup. execvpe replaces this
|
||||
# process outright, one generation from socat, and lets claude's own
|
||||
# stdout/stderr become the socket directly — no capturing/relaying code
|
||||
# needed. stdin is explicitly pointed at /dev/null first since claude
|
||||
# never reads it anyway (the prompt is the `-p` argv above).
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
|
||||
MCP_CONFIG = "/home/coder/.config/claude/mcp.json"
|
||||
|
||||
|
||||
def read_exact(n):
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = os.read(0, n - len(buf))
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
|
||||
def main():
|
||||
length_bytes = read_exact(4)
|
||||
if len(length_bytes) != 4:
|
||||
print(json.dumps({"is_error": True, "result": "malformed request (missing length prefix)"}))
|
||||
return
|
||||
(length,) = struct.unpack("!I", length_bytes)
|
||||
prompt = read_exact(length).decode(errors="replace")
|
||||
if not prompt.strip():
|
||||
print(json.dumps({"is_error": True, "result": "empty prompt"}))
|
||||
return
|
||||
|
||||
env = {k: v for k, v in os.environ.items() if k != "ANTHROPIC_API_KEY"}
|
||||
|
||||
devnull_fd = os.open(os.devnull, os.O_RDONLY)
|
||||
os.dup2(devnull_fd, 0)
|
||||
os.close(devnull_fd)
|
||||
|
||||
# `-p`/`--print` consumes the very next argv token as the prompt itself,
|
||||
# not a trailing positional independent of flag order — confirmed by
|
||||
# testing: putting other flags between `-p` and the prompt made `-p`
|
||||
# swallow the next flag's name as prompt text instead, and everything
|
||||
# after cascaded into nonsense. Every other flag goes first.
|
||||
argv = [
|
||||
"claude",
|
||||
"--permission-mode", "bypassPermissions",
|
||||
"--output-format", "json",
|
||||
"--mcp-config", MCP_CONFIG,
|
||||
"-p", prompt,
|
||||
]
|
||||
try:
|
||||
os.execvpe("claude", argv, env)
|
||||
except OSError as e:
|
||||
print(json.dumps({"is_error": True, "result": f"failed to launch claude: {e}"}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
# MCP stdio server exposing one tool ("ask") that hands a prompt to a peer
|
||||
# workspace's Claude Code over TCP and returns its final result — the
|
||||
# Claude Code counterpart to kilo/peer-mcp-bridge.py.
|
||||
#
|
||||
# Claude Code has no live agent-protocol session to drive the way `kilo
|
||||
# acp` gives peer-mcp-bridge.py (no session/new, no streamed tool_call
|
||||
# updates, no mid-run permission callback to answer). Instead this talks to
|
||||
# claude-peer-listen.sh's socat listener, which runs a single headless
|
||||
# `claude -p --permission-mode bypassPermissions` invocation per connection
|
||||
# via claude-peer-runner.py and returns one `--output-format json` object
|
||||
# once that run finishes. So this bridge is simpler than the kilo one: send
|
||||
# the token, send the length-prefixed prompt, read the response until the
|
||||
# peer closes its end, and parse the one JSON object out of it.
|
||||
#
|
||||
# The prompt is length-prefixed (4-byte big-endian byte count, then that
|
||||
# many bytes), NOT terminated by half-closing the connection
|
||||
# (`shutdown(SHUT_WR)`) the way an earlier version of this did. Confirmed
|
||||
# live against real workspace containers: that half-close made socat tear
|
||||
# the connection down (or stop relaying output — not fully diagnosed
|
||||
# which) the instant the far side's response took more than an instant to
|
||||
# produce, regardless of what was actually running there — reproduced with
|
||||
# a plain `sleep N; echo` script that never touched Claude Code at all. A
|
||||
# real `claude -p` run doing actual work is never "an instant," so
|
||||
# half-closing here would silently and consistently return nothing.
|
||||
import argparse
|
||||
import json
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
|
||||
TOOL_NAME = "ask"
|
||||
|
||||
|
||||
def read_stdin_messages():
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
yield json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
|
||||
def write_message(obj):
|
||||
sys.stdout.write(json.dumps(obj) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def send_response(msg_id, result=None, error=None):
|
||||
msg = {"jsonrpc": "2.0", "id": msg_id}
|
||||
if error is not None:
|
||||
msg["error"] = error
|
||||
else:
|
||||
msg["result"] = result
|
||||
write_message(msg)
|
||||
|
||||
|
||||
class PeerConnectionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ClaudePeer:
|
||||
def __init__(self, host, port, timeout, token):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
self.token = token
|
||||
|
||||
def ask(self, prompt_text):
|
||||
try:
|
||||
sock = socket.create_connection((self.host, self.port), timeout=15)
|
||||
except OSError as e:
|
||||
raise PeerConnectionError(f"could not reach peer Claude bridge {self.host}:{self.port}: {e}")
|
||||
|
||||
with sock:
|
||||
sock.settimeout(self.timeout)
|
||||
sock.sendall((self.token + "\n").encode())
|
||||
prompt_bytes = prompt_text.encode()
|
||||
sock.sendall(struct.pack("!I", len(prompt_bytes)))
|
||||
sock.sendall(prompt_bytes)
|
||||
|
||||
chunks = []
|
||||
try:
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
except socket.timeout:
|
||||
raise TimeoutError(f"peer did not finish within {self.timeout}s")
|
||||
|
||||
raw = b"".join(chunks).decode(errors="replace").strip()
|
||||
if not raw:
|
||||
raise PeerConnectionError("peer closed the connection with no response (bad token?)")
|
||||
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return raw # fall back to raw text rather than hide a malformed-but-useful reply
|
||||
|
||||
if payload.get("is_error"):
|
||||
raise PeerConnectionError(payload.get("result", "peer reported an error"))
|
||||
return payload.get("result") or "(peer produced no text response)"
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--host", required=True)
|
||||
ap.add_argument("--port", type=int, default=4197)
|
||||
ap.add_argument("--peer-name", required=True)
|
||||
ap.add_argument("--timeout", type=int, default=900)
|
||||
args = ap.parse_args()
|
||||
|
||||
# Read from the environment (same as kilo's peer-mcp-bridge.py) rather
|
||||
# than a CLI flag, so the shared secret never ends up in the rendered
|
||||
# MCP config's command array or in `ps` output.
|
||||
import os
|
||||
token = os.environ.get("PEER_BRIDGE_TOKEN", "")
|
||||
if not token:
|
||||
print(json.dumps({"jsonrpc": "2.0", "error": {"code": -32000, "message": "PEER_BRIDGE_TOKEN not set"}}))
|
||||
sys.exit(1)
|
||||
|
||||
peer = ClaudePeer(args.host, args.port, args.timeout, token)
|
||||
|
||||
for msg in read_stdin_messages():
|
||||
method = msg.get("method")
|
||||
msg_id = msg.get("id")
|
||||
|
||||
if method == "initialize":
|
||||
client_version = msg.get("params", {}).get("protocolVersion", "2024-11-05")
|
||||
send_response(msg_id, result={
|
||||
"protocolVersion": client_version,
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": f"claude-peer-bridge-{args.peer_name}", "version": "1.0.0"},
|
||||
})
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
elif method == "tools/list":
|
||||
send_response(msg_id, result={"tools": [{
|
||||
"name": TOOL_NAME,
|
||||
"description": (
|
||||
f"Ask the '{args.peer_name}' workspace's Claude Code agent a question about "
|
||||
"its project, or hand it a task to work on. It runs with full autonomy in its "
|
||||
"own project directory (reads/edits/writes files, runs bash) and returns its "
|
||||
"final response once done. Each call starts a fresh session on the peer's "
|
||||
"side — include any needed context in the prompt itself."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {"type": "string", "description": "The question or task for the peer agent."},
|
||||
},
|
||||
"required": ["prompt"],
|
||||
},
|
||||
}]})
|
||||
elif method == "tools/call":
|
||||
params = msg.get("params", {})
|
||||
if params.get("name") != TOOL_NAME:
|
||||
send_response(msg_id, result={
|
||||
"content": [{"type": "text", "text": f"Unknown tool: {params.get('name')}"}],
|
||||
"isError": True,
|
||||
})
|
||||
continue
|
||||
prompt_text = params.get("arguments", {}).get("prompt", "")
|
||||
try:
|
||||
reply = peer.ask(prompt_text)
|
||||
send_response(msg_id, result={"content": [{"type": "text", "text": reply}], "isError": False})
|
||||
except Exception as e:
|
||||
send_response(msg_id, result={
|
||||
"content": [{"type": "text", "text": f"Peer request failed: {e}"}],
|
||||
"isError": True,
|
||||
})
|
||||
elif msg_id is not None:
|
||||
send_response(msg_id, error={"code": -32601, "message": f"Method not found: {method}"})
|
||||
# else: unrecognized notification, ignore
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user