109 lines
4.8 KiB
Python
109 lines
4.8 KiB
Python
#!/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()
|