diff --git a/tests/test_manager.py b/tests/test_manager.py index db4ebec..8dbb135 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -1023,3 +1023,246 @@ def test_metrics_counts_login_failures(configured_manager): st, body = _req("GET", configured_manager, "/manage/metrics", headers={"Cookie": cookie}) after = int(re.search(r"opencode_railway_login_failures_total (\d+)", body).group(1)) assert after > initial + + +# ─── Wave 3.1: Merge-based .setup.env writes ─────────────────────────────────── + + +def test_reconfigure_preserves_unmanaged_keys(tmp_path, fake_bin): + """Reconfiguring should preserve env vars not managed by the form.""" + port = _free_port() + data = tmp_path / "data" + data.mkdir() + # Use custom provider to avoid needing models.dev fetch in tests + (data / ".setup.env").write_text( + "OPENCODE_SERVER_PASSWORD=testpw\n" + "CUSTOM_API_KEY=fakekey\n" + "OPENCODE_PROVIDER=custom\n" + "OPENCODE_PROVIDER_KEY_ENV=CUSTOM_API_KEY\n" + "OPENCODE_CUSTOM_ID=custom\n" + "OPENCODE_CUSTOM_LABEL=Custom\n" + "OPENCODE_CUSTOM_BASEURL=https://gw.example.com/v1\n" + "OPENCODE_CUSTOM_NPM=@ai-sdk/openai-compatible\n" + "OPENCODE_CUSTOM_ENV=CUSTOM_API_KEY\n" + "OPENCODE_MODEL=custom/m1\n" + "CUSTOM_VAR=should_survive\n" + ) + os.chmod(data / ".setup.env", 0o600) + proc = _start_manager(port, str(data), fake_bin) + try: + assert _wait_ready(port) + cookie = _login(port, "testpw") + st, hdr, body = _req("GET", port, "/setup", headers={"Cookie": cookie}, raw=True) + import re + + m = re.search(r'name="csrf_token" value="([^"]+)"', body.decode("utf-8", "replace")) + csrf = m.group(1) if m else "" + form = ( + f"provider=custom&envvar=CUSTOM_API_KEY&baseurl=https://gw.example.com/v1" + f"&apikey=&model=m1&gitname=oc&gitemail=oc@x&csrf_token={csrf}" + ) + st, _ = _req("POST", port, "/setup", body=form, headers={"Cookie": cookie}) + assert st == 200 + env = (data / ".setup.env").read_text() + assert "CUSTOM_VAR=should_survive" in env + finally: + proc.terminate() + try: + proc.wait(timeout=8) + except subprocess.TimeoutExpired: + proc.kill() + + +def test_reconfigure_mcp_key_preserved_when_rechecked(tmp_path, fake_bin): + """If an MCP is re-checked and the key field is blank, the existing key is preserved.""" + port = _free_port() + data = tmp_path / "data" + data.mkdir() + (data / ".setup.env").write_text( + "OPENCODE_SERVER_PASSWORD=testpw\n" + "CUSTOM_API_KEY=fakekey\n" + "OPENCODE_PROVIDER=custom\n" + "OPENCODE_PROVIDER_KEY_ENV=CUSTOM_API_KEY\n" + "OPENCODE_CUSTOM_ID=custom\n" + "OPENCODE_CUSTOM_LABEL=Custom\n" + "OPENCODE_CUSTOM_BASEURL=https://gw.example.com/v1\n" + "OPENCODE_CUSTOM_NPM=@ai-sdk/openai-compatible\n" + "OPENCODE_CUSTOM_ENV=CUSTOM_API_KEY\n" + "OPENCODE_MODEL=custom/m1\n" + "ENABLED_MCPS=tavily\n" + "TAVILY_API_KEY=tavilykey123\n" + ) + os.chmod(data / ".setup.env", 0o600) + proc = _start_manager(port, str(data), fake_bin) + try: + assert _wait_ready(port) + cookie = _login(port, "testpw") + st, hdr, body = _req("GET", port, "/setup", headers={"Cookie": cookie}, raw=True) + import re + + m = re.search(r'name="csrf_token" value="([^"]+)"', body.decode("utf-8", "replace")) + csrf = m.group(1) if m else "" + form = ( + f"provider=custom&envvar=CUSTOM_API_KEY&baseurl=https://gw.example.com/v1" + f"&apikey=&model=m1&mcp=tavily&gitname=oc&gitemail=oc@x&csrf_token={csrf}" + ) + st, _ = _req("POST", port, "/setup", body=form, headers={"Cookie": cookie}) + assert st == 200 + env = (data / ".setup.env").read_text() + assert "TAVILY_API_KEY=tavilykey123" in env + finally: + proc.terminate() + try: + proc.wait(timeout=8) + except subprocess.TimeoutExpired: + proc.kill() + + +# ─── Wave 3.2: No surprise password regen ────────────────────────────────────── + + +def test_reconfigure_blank_password_keeps_current(tmp_path, fake_bin): + """Reconfiguring with no password change should keep the current password.""" + port = _free_port() + data = tmp_path / "data" + data.mkdir() + (data / ".setup.env").write_text( + "OPENCODE_SERVER_PASSWORD=testpw\n" + "CUSTOM_API_KEY=fakekey\n" + "OPENCODE_PROVIDER=custom\n" + "OPENCODE_PROVIDER_KEY_ENV=CUSTOM_API_KEY\n" + "OPENCODE_CUSTOM_ID=custom\n" + "OPENCODE_CUSTOM_LABEL=Custom\n" + "OPENCODE_CUSTOM_BASEURL=https://gw.example.com/v1\n" + "OPENCODE_CUSTOM_NPM=@ai-sdk/openai-compatible\n" + "OPENCODE_CUSTOM_ENV=CUSTOM_API_KEY\n" + "OPENCODE_MODEL=custom/m1\n" + ) + os.chmod(data / ".setup.env", 0o600) + proc = _start_manager(port, str(data), fake_bin) + try: + assert _wait_ready(port) + cookie = _login(port, "testpw") + st, hdr, body = _req("GET", port, "/setup", headers={"Cookie": cookie}, raw=True) + import re + + m = re.search(r'name="csrf_token" value="([^"]+)"', body.decode("utf-8", "replace")) + csrf = m.group(1) if m else "" + form = ( + f"provider=custom&envvar=CUSTOM_API_KEY&baseurl=https://gw.example.com/v1" + f"&apikey=&model=m1&gitname=oc&gitemail=oc@x&csrf_token={csrf}" + ) + st, body = _req("POST", port, "/setup", body=form, headers={"Cookie": cookie}) + assert st == 200 + env = (data / ".setup.env").read_text() + assert "OPENCODE_SERVER_PASSWORD=testpw" in env + finally: + proc.terminate() + try: + proc.wait(timeout=8) + except subprocess.TimeoutExpired: + proc.kill() + + +def test_reconfigure_change_password_updates(tmp_path, fake_bin): + """Explicitly changing the password should update it.""" + port = _free_port() + data = tmp_path / "data" + data.mkdir() + (data / ".setup.env").write_text( + "OPENCODE_SERVER_PASSWORD=testpw\n" + "CUSTOM_API_KEY=fakekey\n" + "OPENCODE_PROVIDER=custom\n" + "OPENCODE_PROVIDER_KEY_ENV=CUSTOM_API_KEY\n" + "OPENCODE_CUSTOM_ID=custom\n" + "OPENCODE_CUSTOM_LABEL=Custom\n" + "OPENCODE_CUSTOM_BASEURL=https://gw.example.com/v1\n" + "OPENCODE_CUSTOM_NPM=@ai-sdk/openai-compatible\n" + "OPENCODE_CUSTOM_ENV=CUSTOM_API_KEY\n" + "OPENCODE_MODEL=custom/m1\n" + ) + os.chmod(data / ".setup.env", 0o600) + proc = _start_manager(port, str(data), fake_bin) + try: + assert _wait_ready(port) + cookie = _login(port, "testpw") + st, hdr, body = _req("GET", port, "/setup", headers={"Cookie": cookie}, raw=True) + import re + + m = re.search(r'name="csrf_token" value="([^"]+)"', body.decode("utf-8", "replace")) + csrf = m.group(1) if m else "" + form = ( + f"provider=custom&envvar=CUSTOM_API_KEY&baseurl=https://gw.example.com/v1" + f"&apikey=&model=m1&change_password=newpw123&gitname=oc&gitemail=oc@x&csrf_token={csrf}" + ) + st, body = _req("POST", port, "/setup", body=form, headers={"Cookie": cookie}) + assert st == 200 + env = (data / ".setup.env").read_text() + assert "OPENCODE_SERVER_PASSWORD=newpw123" in env + finally: + proc.terminate() + try: + proc.wait(timeout=8) + except subprocess.TimeoutExpired: + proc.kill() + + +def test_first_run_blank_password_auto_generates(tmp_path, fake_bin): + """First-run with blank password should auto-generate one.""" + port = _free_port() + data = tmp_path / "data" + data.mkdir() + proc = _start_manager(port, str(data), fake_bin) + try: + assert _wait_ready(port) + st, hdr, body = _req("GET", port, "/", raw=True) + import re + + m = re.search(r'name="csrf_token" value="([^"]+)"', body.decode("utf-8", "replace")) + csrf = m.group(1) if m else "" + csrf_cookie = "" + ch = hdr.get("Set-Cookie", "") + if "oc_csrf=" in ch: + csrf_cookie = ch.split("oc_csrf=")[1].split(";")[0] + # First-run with blank password + form = ( + "provider=custom&envvar=CUSTOM_API_KEY&baseurl=https://gw.example.com/v1" + "&apikey=fakekey&model=m1&gitname=oc&gitemail=oc@x&csrf_token=" + csrf + ) + st, hdr, body = _req( + "POST", + port, + "/setup", + body=form, + headers={"Cookie": f"oc_csrf={csrf_cookie}"} if csrf_cookie else {}, + raw=True, + ) + assert st == 200 + env = (data / ".setup.env").read_text() + # Password should be auto-generated (not empty) + assert "OPENCODE_SERVER_PASSWORD=" in env + pw_line = [line for line in env.split("\n") if line.startswith("OPENCODE_SERVER_PASSWORD=")][0] + assert len(pw_line.split("=", 1)[1]) > 10 # auto-generated is ~24 chars + finally: + proc.terminate() + try: + proc.wait(timeout=8) + except subprocess.TimeoutExpired: + proc.kill() + + +# ─── Wave 3.4: Graceful drain on restart ──────────────────────────────────────── + + +def test_proxy_503_has_retry_after(configured_manager): + """The 503 response during child downtime should have a Retry-After header.""" + cookie = _login(configured_manager, "testpw") + csrf = _csrf_token("testpw") + # Restart the child — during the brief downtime, a request should get 503+Retry-After + _req("POST", configured_manager, "/manage/restart", headers={"Cookie": cookie}, body=f"csrf_token={csrf}", raw=True) + # Immediately try to proxy — might catch the draining window + st, hdr, _ = _req("GET", configured_manager, "/", headers={"Cookie": cookie}, raw=True) + # It might be 200 (child came back fast) or 503 (still restarting) + if st == 503: + assert "Retry-After" in hdr + assert hdr["Retry-After"] == "10" diff --git a/wizard.py b/wizard.py index 5eb5137..941ea4e 100644 --- a/wizard.py +++ b/wizard.py @@ -810,6 +810,7 @@ def __init__(self, data_dir, internal_port, log_ring): self.log_ring = log_ring self.proc = None self.stopping = False + self.draining = False # Wave 3.4: set during restart to signal 503+Retry-After self.crashes = 0 self._lock = threading.Lock() # ── Runtime telemetry (Wave 2.1) ── @@ -961,12 +962,14 @@ def stop(self): def restart(self, reason="manual"): self.crashes = 0 + self.draining = True # Set the restart reason so _watch records it correctly instead of "stopped" self._restart_reason = reason self.stop() self.stopping = False with self._lock: self._spawn() + self.draining = False threading.Thread(target=self._watch, daemon=True).start() def is_up(self): @@ -1326,10 +1329,7 @@ def _write_locked(self, mapping):
3 Server
-
- - -
+ __PASSWORD_FIELD__
@@ -1867,13 +1867,30 @@ def _render_form(self): .replace("__MODEL_VAL__", html.escape(default_model_val)) .replace("__REPO_VAL__", html.escape(prev.get("GIT_REPO", ""))) .replace("__BRANCH_VAL__", html.escape(prev.get("GIT_REPO_BRANCH", ""))) - .replace("__PW_VAL__", html.escape(prev.get("OPENCODE_SERVER_PASSWORD", ""))) .replace("__GITNAME_VAL__", html.escape(prev.get("GIT_USER_NAME", "opencode"))) .replace("__GITEMAIL_VAL__", html.escape(prev.get("GIT_USER_EMAIL", "opencode@railway.local"))) .replace("__MCP_ROWS__", mcp_rows_html) .replace("__SKILL_ROWS__", skill_rows_html) .replace("__MCP_CUSTOM_PREFILL__", mcp_custom_prefill_js) ) + # Wave 3.2: Show the right password field based on configured state + if self.manager.configured: + pw_field = ( + '
' + '' + '' + "
" + ) + else: + pw_field = ( + '
' + '' + '' + "
" + ) + page = page.replace("__PASSWORD_FIELD__", pw_field) page = page.replace( 'id="apikey" type="password"', f'id="apikey" type="password" data-already-set="{already_set}"', @@ -2021,7 +2038,35 @@ def _handle_setup(self, f, f_multi=None): if not apikey and env_var_already_set(envvar): apikey = os.environ[envvar] - password = (f.get("password") or "").strip() or secrets.token_urlsafe(18) + # ── Wave 3.3: server-side test-before-apply on provider key ── + # Only on reconfigure (not first-run — the standalone Test button is + # the validation path there). If the key is non-empty and differs from + # the current one (or the provider changed), validate it live before + # writing .setup.env. + if self.manager.configured: + prev_env = load_existing(self.data_dir) + prev_provider = prev_env.get("OPENCODE_PROVIDER", "") + prev_key = os.environ.get(envvar, prev_env.get(envvar, "")) + key_changed = apikey and (apikey != prev_key or provider != prev_provider) + if key_changed: + ok, msg = validate_provider_key(provider, apikey, providers, custom) + if not ok: + return self._send(400, self._err_page(f"Provider key validation failed: {msg}")) + + # ── Wave 3.2: no surprise password regen ── + # Blank password = keep current (if configured). A separate explicit + # "change password" field triggers a change. First-run still auto-generates. + change_password = (f.get("change_password") or "").strip() + if self.manager.configured: + # Reconfigure: keep current password unless explicitly changed + if change_password: + password = change_password + else: + password = self.manager.password + else: + # First-run: use the provided password or auto-generate + password = (f.get("password") or "").strip() or secrets.token_urlsafe(18) + # Model selection is handled by opencode's /models at runtime for known # providers. Custom endpoints aren't in models.dev, so opencode won't # know their model ids — require one here and persist it as custom/. @@ -2039,36 +2084,54 @@ def _handle_setup(self, f, f_multi=None): gitname = (f.get("gitname") or "opencode").strip() or "opencode" gitemail = (f.get("gitemail") or "").strip() or "opencode@railway.local" - env_path = os.path.join(self.data_dir, ".setup.env") - lines = [ - "# Written by opencode setup wizard. Do not commit.", - f"OPENCODE_PROVIDER={shlex.quote(provider)}", - f"OPENCODE_PROVIDER_KEY_ENV={shlex.quote(envvar)}", - f"{envvar}={shlex.quote(apikey)}", - f"OPENCODE_SERVER_PASSWORD={shlex.quote(password)}", - f"GIT_USER_NAME={shlex.quote(gitname)}", - f"GIT_USER_EMAIL={shlex.quote(gitemail)}", - ] - if model: - lines.append(f"OPENCODE_MODEL={shlex.quote(model)}") - if repo: - lines.append(f"GIT_REPO={shlex.quote(repo)}") - if branch: - lines.append(f"GIT_REPO_BRANCH={shlex.quote(branch)}") - if ghtoken: - lines.append(f"GITHUB_TOKEN={shlex.quote(ghtoken)}") + # ── Wave 3.1: merge-based .setup.env writes ── + # Load the existing .setup.env and merge the form fields into it, + # preserving any env vars not managed by the form (e.g. keys rotated + # via /manage/keys/rotate, or custom env vars). + prev = load_existing(self.data_dir) + managed = dict(prev) # start from existing, overwrite below + + # Form-managed keys — these are the only keys the form overwrites + managed["OPENCODE_PROVIDER"] = provider + managed["OPENCODE_PROVIDER_KEY_ENV"] = envvar + managed[envvar] = apikey + managed["OPENCODE_SERVER_PASSWORD"] = password + managed["GIT_USER_NAME"] = gitname + managed["GIT_USER_EMAIL"] = gitemail + + # Optional fields: set if provided, clear if empty (but only for + # form-managed keys — don't touch unknown keys) + for key, val in [("OPENCODE_MODEL", model), ("GIT_REPO", repo), ("GIT_REPO_BRANCH", branch), ("GITHUB_TOKEN", ghtoken)]: + if val: + managed[key] = val + elif key in managed: + # Clear if the form explicitly left it empty (but only if it + # was a form-managed key, not an out-of-band addition) + if key in ("OPENCODE_MODEL", "GIT_REPO", "GIT_REPO_BRANCH", "GITHUB_TOKEN"): + managed.pop(key, None) + + # Custom provider fields if is_custom: - lines.append(f"OPENCODE_CUSTOM_ID={shlex.quote(custom['id'])}") - lines.append(f"OPENCODE_CUSTOM_LABEL={shlex.quote(custom['label'])}") - lines.append(f"OPENCODE_CUSTOM_BASEURL={shlex.quote(custom['baseurl'])}") - lines.append(f"OPENCODE_CUSTOM_NPM={shlex.quote(custom['npm'])}") - lines.append(f"OPENCODE_CUSTOM_ENV={shlex.quote(custom['env'])}") + managed["OPENCODE_CUSTOM_ID"] = custom["id"] + managed["OPENCODE_CUSTOM_LABEL"] = custom["label"] + managed["OPENCODE_CUSTOM_BASEURL"] = custom["baseurl"] + managed["OPENCODE_CUSTOM_NPM"] = custom["npm"] + managed["OPENCODE_CUSTOM_ENV"] = custom["env"] + else: + # Clear custom provider fields if switching back to a known provider + for k in ( + "OPENCODE_CUSTOM_ID", + "OPENCODE_CUSTOM_LABEL", + "OPENCODE_CUSTOM_BASEURL", + "OPENCODE_CUSTOM_NPM", + "OPENCODE_CUSTOM_ENV", + ): + managed.pop(k, None) # MCP servers (opt-in via checkboxes) enabled_mcps = f_multi.get("mcp", []) if enabled_mcps: - lines.append(f"ENABLED_MCPS={shlex.quote(','.join(enabled_mcps))}") - prev_mcp = load_existing(self.data_dir) + managed["ENABLED_MCPS"] = ",".join(enabled_mcps) for mid in enabled_mcps: cfg_mcp = MCP_CATALOG.get(mid) if not cfg_mcp or not cfg_mcp.get("needs_key"): @@ -2076,22 +2139,28 @@ def _handle_setup(self, f, f_multi=None): key_env = cfg_mcp["key_env"] mcp_key = (f.get(f"mcp_key_{mid}") or "").strip() if not mcp_key: - mcp_key = prev_mcp.get(key_env, "") + mcp_key = prev.get(key_env, "") if mcp_key: - lines.append(f"{key_env}={shlex.quote(mcp_key)}") + managed[key_env] = mcp_key + else: + managed.pop("ENABLED_MCPS", None) + # MCP custom JSON mcp_custom = (f.get("mcp_custom") or "").strip() if mcp_custom and mcp_custom != "[]": - lines.append(f"MCP_CUSTOM={shlex.quote(mcp_custom)}") + managed["MCP_CUSTOM"] = mcp_custom + else: + managed.pop("MCP_CUSTOM", None) # Skills (opt-in via checkboxes) enabled_skills = f_multi.get("skill", []) if enabled_skills: - lines.append(f"ENABLED_SKILLS={shlex.quote(','.join(enabled_skills))}") + managed["ENABLED_SKILLS"] = ",".join(enabled_skills) + else: + managed.pop("ENABLED_SKILLS", None) - with open(env_path, "w") as fh: - fh.write("\n".join(lines) + "\n") - os.chmod(env_path, 0o600) + # Write the merged config using the same Settings path as /manage/keys/rotate + self.manager.settings.write(managed) # Reload env synchronously so os.environ (and thus the session secret for # the cookie below) reflects the new password immediately; apply_settings @@ -2105,7 +2174,11 @@ def _handle_setup(self, f, f_multi=None): # page's redirect to / doesn't bounce through /manage/login again. secret = hashlib.sha256(("oc:" + password).encode()).digest() val = make_session_cookie(secret) - body = SUCCESS.replace("__CSS__", CSS).replace("__PW__", html.escape(password)) + # Only show the password on the success page if it was (re)generated + # on first-run, or explicitly changed via the change_password field. + show_pw = not self.manager.configured or bool(change_password) + pw_display = html.escape(password) if show_pw else "(unchanged)" + body = SUCCESS.replace("__CSS__", CSS).replace("__PW__", pw_display) body_b = body.encode() self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") @@ -2285,8 +2358,24 @@ def _render_login(self, error="", next_path=None): def _proxy(self, method, body=b""): mgr = self.manager - if not mgr.child.is_up(): - return self._send(503, "opencode is starting up — retry shortly.", "text/plain") + if mgr.child.draining or not mgr.child.is_up(): + msg = b"opencode is restarting - retry shortly." + self.send_response(503) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(msg))) + self.send_header("Retry-After", "10") + self.send_header("Cache-Control", "no-store") + for k, v in _security_headers(self).items(): + self.send_header(k, v) + self.send_header("Connection", "close") + self.end_headers() + self.close_connection = True + try: + self.wfile.write(msg) + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError): + pass + return try: conn = http.client.HTTPConnection("127.0.0.1", mgr.child.internal_port, timeout=None) out_headers = {}