From b3ec5319614aeb1b85130021e68d3f9e885caa48 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 17 Jul 2026 12:54:31 +0000 Subject: [PATCH 1/3] Add password changes to settings page --- README.md | 6 + modules/agent-box.nix | 303 ++++++++++++++++++++++++++++++++++++++-- tests/settings-page.nix | 107 ++++++++++++-- 3 files changed, 392 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index cba488b..8d73661 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,12 @@ one CAPABILITY_IAM checkbox to the Launch Stack form; opt out with [aws/README.md](./aws/README.md#root-access-via-ssm-session-manager) for details. +**Changing the web password.** Open the settings page (the gear icon next to +the terminal), choose **Change password**, and enter the current password plus +the new password twice. The new password follows the launch-time 16–64 +character policy. Saving replaces the root-owned bcrypt hash, reloads Caddy, +and signs out every browser by rotating the authentication-cookie secret. + **Updating the box.** Click "Update box" on the settings page (the gear icon next to your terminal; the card also shows the running agent-box rev, linked to its GitHub commit), or ask the agent in its terminal to run diff --git a/modules/agent-box.nix b/modules/agent-box.nix index a0618ee..3d3bb0e 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -1278,6 +1278,151 @@ in # a prefix, so the daemon matches this full path). settingsBaseOf = n: "/${n}/settings"; hashFileOf = n: toString cfg.users.${n}.web.passwordHashFile; + # Root-only password rotator for one terminal user (issue #91). The + # settings daemon deliberately remains unprivileged, so it invokes this + # no-argument helper through a per-user sudo rule and sends the old/new + # passwords over stdin. User and file paths are compiled into the store + # script: callers cannot redirect the root write toward another user's + # hash (or any other path). + passwordHelperOf = name: + pkgs.writers.writePython3Bin "agent-box-password-${name}" { + libraries = [ pkgs.python3Packages.bcrypt ]; + flakeIgnore = [ "E501" "E302" "E305" ]; + } '' + import bcrypt + import fcntl + import json + import os + import re + import secrets + import subprocess + import sys + import tempfile + + HASH_FILE = ${builtins.toJSON (hashFileOf name)} + COOKIE_FILE = ${builtins.toJSON "/var/lib/agent-box-web/cookie-secret-${name}"} + AUTH_ENV_FILE = "/run/agent-box-web/env" + AUTH_ENV_LOCK = "/run/agent-box-web/password-change.lock" + ENV_SUFFIX = ${builtins.toJSON (envName name)} + SYSTEMCTL = "/run/current-system/sw/bin/systemctl" + PASSWORD_RE = re.compile(r"^[A-Za-z0-9._~-]{16,64}$") + + + def fail(code, message): + # Never include supplied passwords in output. The daemon logs only + # the return code, but this also keeps direct invocations safe. + sys.stderr.write("agent-box password helper: " + message + "\n") + raise SystemExit(code) + + + def atomic_replace(path, data): + """Atomically replace a root secret, preserving owner and mode.""" + directory = os.path.dirname(path) + try: + st = os.stat(path) + uid, gid, mode = st.st_uid, st.st_gid, st.st_mode & 0o777 + except FileNotFoundError: + uid, gid, mode = 0, 0, 0o600 + fd, tmp = tempfile.mkstemp(dir=directory, prefix=".password.") + try: + os.fchmod(fd, mode) + os.fchown(fd, uid, gid) + with os.fdopen(fd, "wb") as fh: + fh.write(data) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + + + def update_auth_env(new_hash, new_cookie): + """Replace this user's two values in Caddy's runtime env.""" + replacements = { + "WEB_PASSWORD_HASH_" + ENV_SUFFIX: new_hash, + "WEB_COOKIE_SECRET_" + ENV_SUFFIX: new_cookie, + } + try: + with open(AUTH_ENV_FILE, "r", encoding="utf-8") as fh: + lines = fh.read().splitlines() + except OSError: + fail(5, "could not read web authentication environment") + output = [] + seen = set() + for line in lines: + key = line.split("=", 1)[0] + if key in replacements: + output.append(key + "=" + replacements[key]) + seen.add(key) + else: + output.append(line) + for key in sorted(set(replacements) - seen): + output.append(key + "=" + replacements[key]) + atomic_replace(AUTH_ENV_FILE, ("\n".join(output) + "\n").encode("utf-8")) + + + def main(): + try: + request = json.load(sys.stdin) + except (UnicodeError, ValueError): + fail(4, "invalid request") + if not isinstance(request, dict) or set(request) != {"previous", "new"}: + fail(4, "invalid request") + previous = request["previous"] + new = request["new"] + if not isinstance(previous, str) or not isinstance(new, str): + fail(4, "invalid request") + if not PASSWORD_RE.fullmatch(new) or new == previous: + fail(4, "invalid new password") + + try: + with open(HASH_FILE, "rb") as fh: + current_hash = fh.read().strip() + matches = bcrypt.checkpw(previous.encode("utf-8"), current_hash) + except (OSError, UnicodeError, ValueError): + fail(5, "could not validate current password") + if not matches: + fail(2, "current password is incorrect") + + new_hash = bcrypt.hashpw( + new.encode("utf-8"), bcrypt.gensalt(rounds=14) + ).decode("ascii") + new_cookie = secrets.token_hex(32) + + # Serialize password changes across users while updating the two + # persistent secrets and their root-owned runtime projection. + # Cookie auth bypasses basic auth after the first login, so rotating + # the cookie secret signs out every browser. + try: + lock_fd = os.open(AUTH_ENV_LOCK, os.O_CREAT | os.O_RDWR, 0o600) + with os.fdopen(lock_fd, "w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + atomic_replace(HASH_FILE, (new_hash + "\n").encode("ascii")) + atomic_replace(COOKIE_FILE, (new_cookie + "\n").encode("ascii")) + update_auth_env(new_hash, new_cookie) + + # systemd re-reads EnvironmentFile for ExecReload, so the + # running listener adopts both values without a restart or + # dropping the in-flight settings response. + subprocess.run( + [SYSTEMCTL, "reload", "caddy.service"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except (OSError, subprocess.CalledProcessError): + fail(5, "password saved but web authentication reload failed") + + + if __name__ == "__main__": + main() + ''; + passwordHelperCmdOf = name: + "${passwordHelperOf name}/bin/agent-box-password-${name}"; # The settings daemon script (issue #36). Python-3-stdlib only — no # third-party deps — so it stays tiny and auditable. Runs as the agent # user; writes ~/.config/agent-box/env (0600) and restarts the agent by @@ -1350,6 +1495,8 @@ in # workspace at / (primary web user) # AGENT_BOX_AGENTS comma-separated installed agent CLIs # AGENT_BOX_DEFAULT_AGENT agent preselected in the add form + # AGENT_BOX_PASSWORD_CMD no-argument sudo command that verifies + # and replaces this user's web password import html import http.server @@ -1394,6 +1541,10 @@ in # used by its non-blocking GitHub update check. REPO = os.environ.get("AGENT_BOX_REPO", "") REV = os.environ.get("AGENT_BOX_REV", "") + # Per-user, no-argument privileged helper (issue 91). Passwords are sent + # as JSON on stdin, never argv or environment, and helper output is never + # reflected into HTTP responses. + PASSWORD_CMD = os.environ.get("AGENT_BOX_PASSWORD_CMD", "") # Env var names: POSIX-ish. Must start with a letter or underscore and # contain only letters, digits, underscores. This is what a shell / systemd @@ -1402,6 +1553,9 @@ in # Session names: same charset the supervisor and CLI enforce (they # land in tmux -t targets and URLs). SESSION_RE = re.compile(r"^[A-Za-z0-9_-]{1,32}$") + # Keep password changes consistent with the CloudFormation launch-time + # contract (and below bcrypt's 72-byte input ceiling). + PASSWORD_RE = re.compile(r"^[A-Za-z0-9._~-]{16,64}$") def read_keys(): @@ -1623,6 +1777,28 @@ in sys.stderr.write("update_box: %s\n" % exc) + def change_password(previous, new): + """Ask the root helper to verify and rotate the web credentials. + + Return 0 on success, 2 for a wrong current password, and another + nonzero value for an operational failure. Passwords cross sudo on + stdin only; neither argv, the environment nor the journal sees them. + """ + try: + proc = subprocess.run( + PASSWORD_CMD.split(), + input=json.dumps({"previous": previous, "new": new}), + text=True, + check=False, + capture_output=True, + ) + sys.stderr.write("change_password: helper rc=%d\n" % proc.returncode) + return proc.returncode + except OSError as exc: + sys.stderr.write("change_password: %s\n" % exc) + return 5 + + # Page skeleton. HEAD_TPL and BODY go through str.format (hence no # literal braces in them); STYLE and SCRIPT are plain strings so CSS/JS # braces need no doubling. The layout mirrors GitHub's environment- @@ -1707,6 +1883,10 @@ in input[type=text] { width: 200px; max-width: 100%; } input[type=password] { width: 280px; max-width: 100%; } .row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } + .fields { display: grid; grid-template-columns: 1fr; gap: 12px; } + .field { display: flex; flex-direction: column; align-items: flex-start; + gap: 4px; color: #8b949e; font-size: 13px; } + .field input { box-sizing: border-box; width: 100%; } form.inline { display: inline; } .msg { padding: 10px 14px; border-radius: 8px; margin: 12px 0; border: 1px solid rgba(63,185,80,.4); background: #10251a; @@ -1849,6 +2029,7 @@ in
{keys}
+ {password_section}

Danger zone