Add devbox image Dockerfiles and build scripts, split from homelab-komodo
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
# Gate for acp-bridge-listen.sh's socat listeners: reads exactly one line
|
||||
# (unbuffered — a normal buffered readline could silently swallow bytes
|
||||
# belonging to the real protocol stream that follows, which would be lost on
|
||||
# exec) from the connection and compares it to PEER_BRIDGE_TOKEN. Only on a
|
||||
# match does it exec into the real target, inheriting the same socket fds so
|
||||
# everything after the token line passes through untouched.
|
||||
#
|
||||
# Without this, acp-bridge-listen.sh's ACP port would be a fully
|
||||
# auto-approving agent shell (bash, edit, write) reachable, unauthenticated,
|
||||
# by anything on the same docker network — not just the specific peer
|
||||
# workspace it's meant for.
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def read_line_unbuffered():
|
||||
buf = b""
|
||||
fd = sys.stdin.fileno()
|
||||
while True:
|
||||
b = os.read(fd, 1)
|
||||
if not b or b == b"\n":
|
||||
break
|
||||
buf += b
|
||||
return buf.decode(errors="replace")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: acp-bridge-auth.py <command> [args...]", file=sys.stderr)
|
||||
print(" acp-bridge-auth.py --print-env <VAR_NAME>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
expected = os.environ.get("PEER_BRIDGE_TOKEN", "")
|
||||
got = read_line_unbuffered()
|
||||
if not expected or got != expected:
|
||||
sys.exit(1)
|
||||
|
||||
# --print-env avoids threading a shell one-liner through socat's own EXEC
|
||||
# argument quoting just to echo an already-inherited env var.
|
||||
if sys.argv[1] == "--print-env":
|
||||
print(os.environ.get(sys.argv[2], ""))
|
||||
sys.exit(0)
|
||||
|
||||
os.execvp(sys.argv[1], sys.argv[1:])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
# Exposes this workspace's `kilo acp` (Agent Client Protocol) agent session
|
||||
# over a plain TCP socket so peer workspaces can drive a real, tool-capable
|
||||
# Kilo session here — ACP itself only speaks JSON-RPC over stdio, with no
|
||||
# network transport of its own. socat's `fork` spawns a fresh `kilo acp`
|
||||
# process per connection, so concurrent peer requests get fully isolated
|
||||
# sessions.
|
||||
#
|
||||
# A second, tiny listener answers with this workspace's actual project
|
||||
# directory as plain text. ACP's session/new takes a `cwd` param that
|
||||
# genuinely governs where tool calls (bash, edit, read, ...) execute —
|
||||
# confirmed by test: it overrides the spawned process's real OS cwd, it's
|
||||
# not just informational — and the calling peer has no other way to learn
|
||||
# this workspace's project path.
|
||||
#
|
||||
# Both listeners are gated behind acp-bridge-auth.py, which requires a
|
||||
# shared-secret token (PEER_BRIDGE_TOKEN) as the connection's first line
|
||||
# before proceeding — without it, this would be a fully auto-approving agent
|
||||
# shell (bash, edit, write) reachable, unauthenticated, by anything on the
|
||||
# same docker network, not just the intended peer.
|
||||
#
|
||||
# Lives outside /home/coder — see vnc-startup.sh header comment for why.
|
||||
set -e
|
||||
|
||||
ACP_PORT="${1:-4097}"
|
||||
INFO_PORT="${2:-4098}"
|
||||
PROJECT_DIR="${PROJECT_DIR:-/home/coder}"
|
||||
|
||||
mkdir -p ~/.local/log ~/.local/run
|
||||
|
||||
for name in acp-bridge acp-bridge-info; do
|
||||
if [ -f ~/.local/run/$name.pid ]; then
|
||||
kill "$(cat ~/.local/run/$name.pid)" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
setsid nohup socat TCP-LISTEN:"$ACP_PORT",fork,reuseaddr EXEC:"python3 /usr/local/bin/acp-bridge-auth.py kilo acp" \
|
||||
> ~/.local/log/acp-bridge.log 2>&1 < /dev/null &
|
||||
echo $! > ~/.local/run/acp-bridge.pid
|
||||
|
||||
setsid nohup socat TCP-LISTEN:"$INFO_PORT",fork,reuseaddr EXEC:"python3 /usr/local/bin/acp-bridge-auth.py --print-env PROJECT_DIR" \
|
||||
> ~/.local/log/acp-bridge-info.log 2>&1 < /dev/null &
|
||||
echo $! > ~/.local/run/acp-bridge-info.pid
|
||||
|
||||
echo "[ Kilo peer bridge (ACP) listening on :$ACP_PORT (project dir info on :$INFO_PORT) ]"
|
||||
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
# MCP stdio server exposing one tool ("ask") that opens a live ACP
|
||||
# (Agent Client Protocol) session against a peer Kilo workspace over TCP and
|
||||
# returns its final response. This exists because kilo serve has no MCP
|
||||
# server endpoint of its own (confirmed by dumping every route in the kilo
|
||||
# binary) — kilo acp is a real, tool-capable agent session, but it only
|
||||
# speaks JSON-RPC over stdio, so acp-bridge-listen.sh exposes it via socat
|
||||
# and this script bridges that into something kilo (as an MCP client) can
|
||||
# call as an ordinary "local" MCP server.
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
|
||||
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 AcpPeer:
|
||||
def __init__(self, host, acp_port, info_port, model, timeout, token):
|
||||
self.host = host
|
||||
self.acp_port = acp_port
|
||||
self.info_port = info_port
|
||||
self.model = model
|
||||
self.timeout = timeout
|
||||
self.token = token
|
||||
|
||||
def _fetch_project_dir(self):
|
||||
try:
|
||||
with socket.create_connection((self.host, self.info_port), timeout=10) as s:
|
||||
wf = s.makefile("w")
|
||||
wf.write(self.token + "\n")
|
||||
wf.flush()
|
||||
f = s.makefile("r")
|
||||
line = f.readline().strip()
|
||||
return line or "/home/coder"
|
||||
except OSError as e:
|
||||
raise PeerConnectionError(f"could not reach peer info port {self.host}:{self.info_port}: {e}")
|
||||
|
||||
def ask(self, prompt_text):
|
||||
cwd = self._fetch_project_dir()
|
||||
deadline = time.time() + self.timeout
|
||||
|
||||
try:
|
||||
sock = socket.create_connection((self.host, self.acp_port), timeout=15)
|
||||
except OSError as e:
|
||||
raise PeerConnectionError(f"could not reach peer ACP port {self.host}:{self.acp_port}: {e}")
|
||||
|
||||
with sock:
|
||||
sock.settimeout(self.timeout)
|
||||
rf = sock.makefile("r")
|
||||
wf = sock.makefile("w")
|
||||
|
||||
wf.write(self.token + "\n")
|
||||
wf.flush()
|
||||
|
||||
def send(msg):
|
||||
wf.write(json.dumps(msg) + "\n")
|
||||
wf.flush()
|
||||
|
||||
def recv():
|
||||
line = rf.readline()
|
||||
if not line:
|
||||
return None
|
||||
return json.loads(line)
|
||||
|
||||
def await_response(expected_id, allow_error=False):
|
||||
while True:
|
||||
obj = recv()
|
||||
if obj is None:
|
||||
raise PeerConnectionError("peer connection closed unexpectedly")
|
||||
if obj.get("id") == expected_id:
|
||||
if "error" in obj and not allow_error:
|
||||
raise PeerConnectionError(f"peer error: {obj['error']}")
|
||||
return obj
|
||||
# ignore interleaved notifications while waiting for this response
|
||||
|
||||
send({"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {"protocolVersion": 1, "clientCapabilities": {}}})
|
||||
await_response(1)
|
||||
|
||||
send({"jsonrpc": "2.0", "id": 2, "method": "session/new",
|
||||
"params": {"cwd": cwd, "mcpServers": []}})
|
||||
resp = await_response(2)
|
||||
session_id = resp["result"]["sessionId"]
|
||||
|
||||
send({"jsonrpc": "2.0", "id": 3, "method": "session/set_model",
|
||||
"params": {"sessionId": session_id, "modelId": self.model}})
|
||||
await_response(3, allow_error=True)
|
||||
|
||||
send({"jsonrpc": "2.0", "id": 4, "method": "session/prompt",
|
||||
"params": {"sessionId": session_id,
|
||||
"prompt": [{"type": "text", "text": prompt_text}]}})
|
||||
|
||||
text_parts = []
|
||||
tool_log = []
|
||||
while True:
|
||||
if time.time() > deadline:
|
||||
raise TimeoutError(f"peer did not finish within {self.timeout}s")
|
||||
obj = recv()
|
||||
if obj is None:
|
||||
raise PeerConnectionError("peer connection closed unexpectedly")
|
||||
|
||||
method = obj.get("method")
|
||||
if method == "session/update":
|
||||
upd = obj["params"]["update"]
|
||||
su = upd.get("sessionUpdate")
|
||||
if su == "agent_message_chunk":
|
||||
content = upd.get("content", {})
|
||||
if content.get("type") == "text":
|
||||
text_parts.append(content["text"])
|
||||
elif su == "tool_call_update" and upd.get("status") == "completed":
|
||||
tool_log.append(upd.get("title") or upd.get("kind") or "tool")
|
||||
elif method == "session/request_permission":
|
||||
options = obj.get("params", {}).get("options", [])
|
||||
chosen = None
|
||||
for pref in ("allow_always", "allow_once", "allow"):
|
||||
for opt in options:
|
||||
if pref in str(opt.get("kind", "")):
|
||||
chosen = opt["optionId"]
|
||||
break
|
||||
if chosen:
|
||||
break
|
||||
if chosen is None and options:
|
||||
chosen = options[0]["optionId"]
|
||||
send({"jsonrpc": "2.0", "id": obj["id"],
|
||||
"result": {"outcome": {"outcome": "selected", "optionId": chosen}}})
|
||||
elif obj.get("id") == 4:
|
||||
break
|
||||
# else: ignore anything else (other notifications, stray responses)
|
||||
|
||||
reply = "".join(text_parts).strip()
|
||||
if tool_log:
|
||||
reply += "\n\n[peer actions: " + ", ".join(tool_log) + "]"
|
||||
return reply or "(peer produced no text response)"
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--host", required=True)
|
||||
ap.add_argument("--acp-port", type=int, default=4097)
|
||||
ap.add_argument("--info-port", type=int, default=4098)
|
||||
ap.add_argument("--model", required=True)
|
||||
ap.add_argument("--peer-name", required=True)
|
||||
ap.add_argument("--timeout", type=int, default=600)
|
||||
args = ap.parse_args()
|
||||
|
||||
# Read from the environment (inherited from the agent's own env, same as
|
||||
# acp-bridge-auth.py) rather than a CLI flag, so the shared secret never
|
||||
# ends up in kilo.jsonc's command array or in `ps` output.
|
||||
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 = AcpPeer(args.host, args.acp_port, args.info_port, args.model, 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"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 Kilo 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