Skip to content

Commit 30c68c6

Browse files
ZD Studiosclaude
andcommitted
fix: friendly unlock page instead of raw 401, and don't mistake Docker for WSL
Running the container surfaced two things that only show up outside loopback: - Opening http://localhost:8787 from the host returned raw JSON {"error":"unauthorized"}. The gate is right — Docker's bridge IP isn't loopback, so the token is required — but that's a hostile first experience. A browser GET without a token now gets a themed page explaining why and offering a box to paste the token, which redirects to /?token=…. Non-browser callers still get JSON. - `aios url` inside the container advertised the WSL instructions and a container IP. Docker Desktop runs on the WSL2 kernel, so /proc/version contains "microsoft" and _is_wsl() matched. Added _is_docker() (AIOS_IN_DOCKER or /.dockerenv), checked first, printing the correct host URL with the token. Verified in the running container: tokenless browser request renders the unlock page, ?token=… serves the dashboard, and `aios url` now prints "Docker → open this on your host: http://localhost:8787/?token=…". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 57085af commit 30c68c6

2 files changed

Lines changed: 59 additions & 2 deletions

File tree

aios.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2138,7 +2138,15 @@ def _openclaw_os_url(cfg) -> str | None:
21382138
return cfg_get(cfg, "health.openclaw_os", f"http://127.0.0.1:{port}/plugins/openclawos/")
21392139

21402140

2141+
def _is_docker() -> bool:
2142+
"""Must be checked before _is_wsl(): Docker Desktop runs on the WSL2 kernel, so
2143+
/proc/version says "microsoft" inside a container too."""
2144+
return (os.environ.get("AIOS_IN_DOCKER") == "1" or Path("/.dockerenv").exists())
2145+
2146+
21412147
def _is_wsl() -> bool:
2148+
if _is_docker():
2149+
return False
21422150
try:
21432151
return "microsoft" in Path("/proc/version").read_text(errors="ignore").lower()
21442152
except Exception:
@@ -2159,7 +2167,14 @@ def _print_urls(cfg):
21592167
q = f"?token={tok}" if tok else ""
21602168
say(f"{C.B}★ Control Room — talk to everything{C.R}")
21612169
say(f" {C.CYN}http://127.0.0.1:{hbp}/{C.R} (the AIOS Hub dashboard)")
2162-
if _is_wsl():
2170+
if _is_docker():
2171+
# Ports are published to the host, so localhost is right — but the request
2172+
# arrives from the bridge, which isn't loopback, so it needs the token.
2173+
say(f" {C.B}{C.YEL}Docker → open this on your host: "
2174+
f"http://localhost:{hbp}/{q}{C.R}")
2175+
if not tok:
2176+
say(f" {C.GRY}(run `aios token` inside the container for the token){C.R}")
2177+
elif _is_wsl():
21632178
ip = _wsl_ip()
21642179
if ip:
21652180
# A WSL-IP request is non-loopback, so it MUST carry the token — the browser

aios_hub.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1326,6 +1326,41 @@ def services_status() -> dict:
13261326
# --------------------------------------------------------------------------- #
13271327
# HTTP server #
13281328
# --------------------------------------------------------------------------- #
1329+
# Shown when a browser reaches the hub without a token — which is the normal first
1330+
# experience in Docker or over a LAN, since only loopback is trusted.
1331+
UNLOCK_PAGE = """<!doctype html><meta charset="utf-8">
1332+
<title>The AI OS — token required</title>
1333+
<style>
1334+
:root{color-scheme:dark}
1335+
body{margin:0;min-height:100vh;display:grid;place-items:center;background:#15120c;
1336+
color:#f0e8d6;font:15px/1.6 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
1337+
.card{max-width:520px;padding:34px;border:1px solid #33291d;border-radius:14px;background:#1c1811}
1338+
h1{margin:0 0 6px;font-size:1.35rem;color:#caa63f}
1339+
p{color:#bcac8f;margin:.6em 0}
1340+
code,kbd{background:#100d08;border:1px solid #33291d;border-radius:6px;padding:2px 6px;
1341+
font-family:ui-monospace,Consolas,monospace;font-size:.86em;color:#f0e8d6}
1342+
form{display:flex;gap:8px;margin-top:18px}
1343+
input{flex:1;background:#221c14;border:1px solid #4a3c29;border-radius:9px;padding:11px;
1344+
color:#f0e8d6;font-family:ui-monospace,Consolas,monospace}
1345+
button{background:#caa63f;color:#1a1408;border:0;border-radius:9px;padding:11px 20px;
1346+
font-weight:700;cursor:pointer}
1347+
.hint{font-size:.85rem;color:#8a7c62;margin-top:16px}
1348+
</style>
1349+
<div class="card">
1350+
<h1>🔒 The AI OS — token required</h1>
1351+
<p>You reached the hub from outside <code>localhost</code>, so it needs the access
1352+
token. This is deliberate: the agents have full control of this machine, so the
1353+
control plane is never open.</p>
1354+
<form onsubmit="location='/?token='+encodeURIComponent(this.t.value.trim());return false">
1355+
<input name="t" placeholder="aios_…" autofocus spellcheck="false">
1356+
<button>Unlock</button>
1357+
</form>
1358+
<p class="hint">Find it with <code>aios token</code> — or in Docker:<br>
1359+
<code>docker compose exec aios aios token</code><br>
1360+
It's also printed in <code>docker compose logs</code> on first boot.</p>
1361+
</div>"""
1362+
1363+
13291364
class Handler(BaseHTTPRequestHandler):
13301365
def log_message(self, *a):
13311366
pass # quiet; aios captures stdout separately
@@ -1355,7 +1390,14 @@ def _gate(self) -> bool:
13551390
if not ok:
13561391
brain.audit("http", "denied", f"{self.command} {self.path.split('?')[0]} "
13571392
f"from {self.client_address[0]}: {why}", ok=False)
1358-
self._send(401, {"error": "unauthorized", "reason": why})
1393+
# A person typing http://host:8787 in a browser gets a page they can act
1394+
# on, not raw JSON. Common in Docker/LAN, where the request arrives from
1395+
# a bridge IP and is correctly non-loopback.
1396+
wants_html = "text/html" in (self.headers.get("Accept") or "")
1397+
if wants_html and self.command == "GET":
1398+
self._send(401, UNLOCK_PAGE, "text/html; charset=utf-8")
1399+
else:
1400+
self._send(401, {"error": "unauthorized", "reason": why})
13591401
return ok
13601402

13611403
def do_OPTIONS(self):

0 commit comments

Comments
 (0)