#!/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()