import base64
import fcntl
import json
import os
import re
import shutil
from pathlib import Path
import signal
import socket
import sqlite3
import subprocess
import sys
import time
import urllib.error
import urllib.request
CODE_PORT = 4096
LEGACY_PORT = 8080
OLD_IMAGE = ("This cloud agent is on an older image whose OpenCode is not V2. "
"Create a new agent with railway code --opencode --new.")
V1_SERVER = ("This agent's saved OpenCode server was started as OpenCode 1, which this CLI no longer supports. "
"Create a new agent with railway code --opencode --new.")
class SetupError(Exception):
pass
def save(path, value):
temporary = path.with_suffix(".tmp")
with temporary.open("w") as stream:
os.chmod(temporary, 0o600)
json.dump(value, stream)
temporary.replace(path)
def process_start(pid):
try:
fields = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()
return None if fields[0] == "Z" else fields[19]
except (OSError, IndexError):
return None
def restored_state(state):
current = os.environ.get("RAILWAY_FACTORY_VM_ID")
return bool(current and state.get("vm_id") and state["vm_id"] != current)
def owned_process(state):
if restored_state(state):
return False
pid = state.get("pid")
start = state.get("start")
return isinstance(pid, int) and pid > 1 and start is not None and process_start(pid) == start
def probe(port, credentials, path):
request = urllib.request.Request(f"http://127.0.0.1:{port}/{path}")
if credentials:
token = base64.b64encode(
f"{credentials['username']}:{credentials['password']}".encode()
).decode()
request.add_header("Authorization", f"Basic {token}")
try:
with urllib.request.build_opener(urllib.request.ProxyHandler({})).open(request, timeout=1) as response:
body = json.loads(response.read(65536))
return response.status, body
except urllib.error.HTTPError as error:
return error.code, {}
except (OSError, ValueError):
return None, {}
def server_status(port, credentials=None):
for path in ("api/info", "api/status", "api/health"):
status, body = probe(port, credentials, path)
if status != 404:
return status, body
return status, body
def health(port, credentials=None):
status, body = server_status(port, credentials)
ready = isinstance(body.get("version"), str) and (
body.get("healthy") is True or (type(body.get("pid")) is int and isinstance(body.get("urls"), list))
)
return status, ready
def stop_owned(state):
if not owned_process(state):
return
try:
os.killpg(state["pid"], signal.SIGTERM)
except ProcessLookupError:
return
deadline = time.monotonic() + 5
while owned_process(state) and time.monotonic() < deadline:
time.sleep(0.1)
if owned_process(state):
os.killpg(state["pid"], signal.SIGKILL)
deadline = time.monotonic() + 5
while owned_process(state) and time.monotonic() < deadline:
time.sleep(0.1)
if owned_process(state):
raise SetupError("The OpenCode server did not stop; retry.")
def release_tuple(version):
return tuple(map(int, version.split("."))) if re.fullmatch(r"\d+\.\d+\.\d+", version or "") else None
def v2_runtime(home, minimum=None):
search = f"{home}/.opencode/bin:{home}/.local/bin:" + os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin")
for binary in filter(None, [str(home / ".opencode/bin/opencode"), shutil.which("opencode", path=search)]):
try:
version = executable_version(binary)
except (OSError, subprocess.SubprocessError, SetupError):
continue
floor = release_tuple(minimum)
if version.startswith("2.") and (floor is None or release_tuple(version) >= floor):
return binary, version
raise SetupError(OLD_IMAGE)
def executable_version(binary):
result = subprocess.run([str(binary), "--version"], stdin=subprocess.DEVNULL,
capture_output=True, text=True, timeout=10)
version = result.stdout.strip().removeprefix("opencode v")
if result.returncode or not re.fullmatch(r"\d+\.\d+\.\d+", version):
raise SetupError("The OpenCode executable has an unsupported version.")
return version
def saved_v2(state):
if not state:
return True
protocol = state.get("protocol")
if protocol is None:
return state.get("harness") == "opencode2"
if protocol not in ("v1", "v2"):
raise SetupError("The saved OpenCode protocol is unsupported; update the Railway CLI.")
return protocol == "v2"
def reject_downgrade(current, requested):
if current and requested and current != requested and not current.startswith("0.0.0-beta-"):
if tuple(map(int, current.split("."))) > tuple(map(int, requested.split("."))):
raise SetupError(f"The saved server last ran OpenCode {current}, newer than this image's {requested}; its storage is not reopened by an older release. Create a new agent with railway code --opencode --new.")
def port_available(port):
try:
with socket.socket() as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", port))
return True
except OSError:
return False
def server_port(state):
configured = os.environ.get("RAILWAY_CODE_PORT")
if configured:
if not configured.isascii() or not configured.isdecimal():
raise SetupError("RAILWAY_CODE_PORT must be a valid code endpoint port.")
configured = int(configured)
if not 1024 <= configured <= 65535 or configured in (LEGACY_PORT, 8790):
raise SetupError("RAILWAY_CODE_PORT must be 1024-65535, excluding the app and gateway ports.")
if owned_process(state):
port = state.get("port", LEGACY_PORT)
elif configured:
port = configured
elif state and not restored_state(state):
port = state.get("port", LEGACY_PORT)
else:
port = CODE_PORT if os.environ.get(f"RAILWAY_PUBLIC_DOMAIN_{CODE_PORT}") else LEGACY_PORT
if type(port) is not int or not 1024 <= port <= 65535 or port == 8790:
raise SetupError("The saved OpenCode server has an unsupported port.")
return port
def setup(request, home):
home = Path(home)
if request.get("harness", "opencode") not in ("opencode", "opencode2"):
raise SetupError("Unsupported OpenCode harness.")
if request.get("protocol", "v2") != "v2":
raise SetupError("Unsupported OpenCode protocol.")
os.umask(0o077)
root = Path(home) / ".railway" / "desktop" / "opencode"
state_path = root / "server.json"
if request.get("action") == "inspect":
state = json.loads(state_path.read_text()) if state_path.exists() else {}
if not saved_v2(state):
return None
port = server_port(state)
if (owned_process(state)
and health(port, state) == (200, True)
and health(port)[0] == 401):
return {"directory": state.get("directory") or str(Path(f"/proc/{state['pid']}/cwd").resolve())}
return None
if request.get("action") == "connect" and not state_path.exists():
raise SetupError("This agent has no managed OpenCode server. Set one up with railway code first.")
root.mkdir(parents=True, exist_ok=True, mode=0o700)
os.chmod(root, 0o700)
with (root / "setup.lock").open("w") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise SetupError("Another OpenCode setup is running on this agent. Retry when it finishes.")
state = json.loads(state_path.read_text()) if state_path.exists() else {}
if request.get("action") == "stop":
stop_owned(state)
state.pop("pid", None)
state.pop("start", None)
if state:
save(state_path, state)
return {"stopped": True}
if not saved_v2(state):
raise SetupError(V1_SERVER)
port = server_port(state)
if request.get("action") == "connect":
if not state.get("password") or not state.get("username"):
raise SetupError("This agent has no server for the selected OpenCode edition.")
directory = state.get("directory")
if not directory and owned_process(state):
directory = str(Path(f"/proc/{state['pid']}/cwd").resolve())
if not directory:
raise SetupError("The saved server has no project directory. Rerun railway code with --agent and --dir to reconnect.")
request = dict(request, directory=directory, password=state["password"])
directory = str(Path(request["directory"]).resolve(strict=True))
if not Path(directory).is_dir():
raise SetupError("OpenCode's working directory must be a directory.")
domain = os.environ.get(f"RAILWAY_PUBLIC_DOMAIN_{port}")
if not domain and port == LEGACY_PORT:
domain = os.environ.get("RAILWAY_PUBLIC_DOMAIN")
if not domain:
raise SetupError(f"This agent has no public address for port {port}.")
credentials = {
"username": state.get("username") or os.environ.get("OPENCODE_SERVER_USERNAME") or "opencode",
"password": state.get("password") or os.environ.get("OPENCODE_SERVER_PASSWORD") or request["password"],
}
if not state:
credentials["username"] = "opencode"
if not all(isinstance(value, str) and value for value in credentials.values()):
raise SetupError("OpenCode requires a nonempty username and password.")
runtime = None
if not port_available(port) and not owned_process(state):
raise SetupError(f"Port {port} is occupied by another process. Stop it or use --new for a fresh agent.")
current = server_status(port, credentials)[1].get("version") if owned_process(state) else None
if not current:
previous = state.get("version")
runtime = v2_runtime(home, previous)
reject_downgrade(previous, runtime[1])
reused = False
if not port_available(port):
if owned_process(state) and health(port, credentials) == (200, True) and health(port)[0] == 401:
reused = True
else:
raise SetupError(f"Port {port} is occupied by another process. Stop it or use --new for a fresh agent.")
if not reused:
if owned_process(state):
raise SetupError("The managed OpenCode process is running but unhealthy. Run --remove, then reconnect.")
environment = dict(os.environ)
environment.update({
"HOME": str(home),
"OPENCODE_SERVER_USERNAME": credentials["username"],
"OPENCODE_SERVER_PASSWORD": credentials["password"],
})
environment["PATH"] = f"{home}/.opencode/bin:{home}/.local/bin:" + environment.get("PATH", "/usr/local/bin:/usr/bin:/bin")
state = dict(credentials, harness="opencode", protocol="v2", directory=directory, port=port,
vm_id=os.environ.get("RAILWAY_FACTORY_VM_ID"), version=runtime[1], binary=str(runtime[0]))
save(state_path, state)
with (root / "server.log").open("w") as log:
child = subprocess.Popen(
[str(runtime[0]), "serve", "--hostname", "0.0.0.0", "--port", str(port)],
cwd=directory, env=environment, stdin=subprocess.DEVNULL,
stdout=log, stderr=subprocess.STDOUT, close_fds=True,
start_new_session=True,
)
state.update({"pid": child.pid, "start": process_start(child.pid)})
save(state_path, state)
deadline = time.monotonic() + 600
while time.monotonic() < deadline:
if child.poll() is not None:
raise SetupError(f"OpenCode exited during startup. Check {root / 'server.log'} on the agent.")
if health(port, credentials) == (200, True):
if health(port)[0] != 401:
os.killpg(child.pid, signal.SIGTERM)
raise SetupError("OpenCode is not enforcing password authentication; stopped it.")
break
time.sleep(0.25)
else:
os.killpg(child.pid, signal.SIGTERM)
raise SetupError(f"OpenCode did not become healthy. Check {root / 'server.log'} on the agent.")
if reused and not state.get("directory"):
cwd = Path(f"/proc/{state['pid']}/cwd")
if cwd.exists():
state["directory"] = str(cwd.resolve())
save(state_path, state)
if reused and not state.get("vm_id") and os.environ.get("RAILWAY_FACTORY_VM_ID"):
state["vm_id"] = os.environ["RAILWAY_FACTORY_VM_ID"]
save(state_path, state)
version = server_status(port, credentials)[1].get("version") or state.get("version")
state.update(protocol="v2", version=version)
save(state_path, state)
return dict(credentials, url=f"https://{domain}", directory=directory, reused=reused, version=version)
if __name__ == "__main__":
try:
result = setup(json.load(sys.stdin), Path.home())
print("RAILWAY_OPENCODE_CONNECTION=" + json.dumps(result), flush=True)
except (SetupError, OSError, ValueError, KeyError) as error:
print(f"OpenCode setup failed: {error}", file=sys.stderr)
sys.exit(1)