diff --git a/keel/web/static/css/keel.css b/keel/web/static/css/keel.css index 563bc0d3..16dd3436 100644 --- a/keel/web/static/css/keel.css +++ b/keel/web/static/css/keel.css @@ -788,6 +788,26 @@ code, .stopped p { margin: 0 0 0.7rem; } .stopped .detail { color: var(--muted); font-size: 0.88rem; } +/* How to get in (#634 follow-up): the command that RECOVERS the address, set apart from the + * detail prose rather than appended to it. The paragraph beside it runs five sentences, and a + * command added as a sixth is a command nobody sees -- which is how two people who knew this + * codebase were locked out and recovered by other means. Boxed, monospaced and selectable, + * because its whole purpose is to be copied into a terminal. */ +.how-to-get-in { margin: 0 0 1rem; } +.how-to-get-in code { + display: block; + margin: 0.35rem 0 0.6rem; + padding: 0.5rem 0.7rem; + border: 1px solid var(--control-line); + border-radius: 4px; + background: var(--code-bg, rgba(127, 127, 127, 0.12)); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.95rem; + /* The one string on this page meant to leave it. */ + user-select: all; + overflow-wrap: anywhere; +} + /* The reconnect field (#634): the one control on a refused page, so it is laid out as a row that * wraps rather than a form, and its border is `--control-line` -- the variable that exists * because WCAG 1.4.11 wants 3:1 on a control boundary and `--line` is decorative only. */ diff --git a/keel/web/static/js/render.js b/keel/web/static/js/render.js index fe68a019..c7b1dd71 100644 --- a/keel/web/static/js/render.js +++ b/keel/web/static/js/render.js @@ -509,6 +509,27 @@ export function stoppedView(reading, setupHref) { return fragment; } +/** + * The `keel open` invocation that recovers THIS console's address. + * + * It returns a NODE and joins nothing, because this file may use neither template literals nor + * `+` -- `test_render_contains_no_arithmetic` bans the operator outright rather than try to tell + * string concatenation from money, and that bluntness is the point of the gate. `append` takes + * several strings, so the port is placed beside the command instead of being joined to it. + * + * A port the browser cannot report -- the default 80/443, where `location.port` is the empty + * string -- yields the bare command, which is still correct: `keel open` has its own default. + * + * @param {string} port `window.location.port`, empty when the URL carries no explicit port. + * @returns {HTMLElement} + */ +function openCommandNode(port) { + const code = el("code"); + if (port) code.append("keel open --port ", port); + else code.append("keel open"); + return code; +} + /** * The "keel refused this browser" view (#634). * @@ -548,8 +569,37 @@ export function refusedView(reading, onReconnect) { card.append(el("p", "detail", reading.error.detail || reading.error.title)); } + // ── HOW TO GET THE VALUE THIS FIELD WANTS ────────────────────────────────────────────── + // Added because the view told an operator what to paste and never where to get it. The + // refusal's own prose says the token "is never written to disk", which since #756 is only + // true of a server attached to a TERMINAL: run detached -- launchd, a pipe, a container -- + // `runtime.py` records the address in a 0600 file precisely so `keel open` can hand it back. + // A reader who believes the sentence concludes the token is unrecoverable and restarts the + // server, which mints a NEW one and is the single worst move available. The command is the + // remedy, and it was named in `server.py`, in the launchd plist and in `runtime.py`'s own + // docstring -- everywhere except the screen the locked-out operator is looking at. + // + // The PORT is taken from `window.location`, the same source and the same reason as + // `timelineExportUrl` and the deployment card: it is a fact about where this page is, not a + // claim from a payload that just refused us. Nothing else about the deployment is named -- + // the path in particular -- because the port is the one thing the reader demonstrably + // already knows (they reached this page on it), so naming it discloses nothing to a request + // that has NOT been admitted. That matters more once #648 lets this console answer beyond + // loopback. + const how = el("div", "detail how-to-get-in"); + how.append(el("p", undefined, "To get this run's address, from your keel deployment directory:")); + how.append(openCommandNode(window.location.port)); + const note = el("p"); + note.append( + "It opens the console, and prints the address so you can paste it below. ", + "Attached to a terminal keel prints the address and keeps nothing; run as a service it ", + "records the address for that command and deletes it on shutdown.", + ); + how.append(note); + card.append(how); + const form = el("form"); - const label = el("label", undefined, "Paste the address keel printed, or just its token"); + const label = el("label", undefined, "Paste the address from that command, or just its token"); label.setAttribute("for", "reconnect-address"); const input = el("input"); input.setAttribute("id", "reconnect-address"); diff --git a/tests/web/test_refused_view_instructions.py b/tests/web/test_refused_view_instructions.py new file mode 100644 index 00000000..bd6d1f4b --- /dev/null +++ b/tests/web/test_refused_view_instructions.py @@ -0,0 +1,95 @@ +"""The refusal must say how to GET the value it asks you to paste (#634 follow-up). + +The evidence this exists for: on 2026-09-16 two people who know this codebase were locked out of +the console by a daemon restart, and neither recovered from the view. One went to `keel open +--help`; the other asked a colleague. The view named the cause well and the remedy not at all. + +Worse than an omission, the refusal's prose says the token "is never written to disk". Since #756 +that is true only of a server attached to a TERMINAL -- run detached, `keel/web/runtime.py` +records the address in a `0600` file precisely so `keel open` can hand it back. A reader who +believes the sentence concludes the token is unrecoverable and restarts the server, which mints a +NEW token: the single worst move available, and the one `refusedView`'s own docstring already +warns about ("sends them to restart the thing that is working"). + +`keel open` was named in `server.py`, in the launchd plist, and in `runtime.py`'s own module +docstring -- everywhere except the screen the locked-out operator is looking at. +""" + +from __future__ import annotations + +from tests.web.test_client_assets import _comments_only +from tests.web.test_view_sections import _JS, _decl + +RENDER = (_JS / "render.js").read_text(encoding="utf-8") + + +def _refused() -> str: + """`refusedView`'s body with comments stripped -- prose must not satisfy these assertions.""" + return _comments_only(_decl(RENDER, "refusedView")) + + +def test_the_refusal_names_the_command_that_recovers_the_address() -> None: + """The remedy, on the screen that needs it. + + Rejects the mutation that started this: a view that tells an operator what to paste and never + where to get it. Asserted on the CODE with comments stripped, so the prose above the function + explaining why the command belongs here cannot stand in for the command. + """ + body = _refused() + assert "openCommandNode" in body, ( + "refusedView no longer builds the `keel open` command -- the view is back to asking for " + "a token it never says how to obtain" + ) + assert body.count("openCommandNode") == 1 + + +def test_the_command_carries_this_console_s_own_port() -> None: + """A deployment runs several consoles (four, on the machine this was found on), so a command + without a port sends the operator to whichever one `keel open` defaults to -- a DIFFERENT + console, answering happily, which reads as the command having failed. + + `window.location` is the source deliberately: the port is a fact about where this page is, + not a claim from a payload that just refused us. Rejects the mutation that hardcodes a port + or drops the argument. + """ + body = _refused() + assert "openCommandNode(window.location.port)" in body + + +def test_the_command_is_a_code_element_not_a_sentence() -> None: + """Placed to be copied, not read past. + + The detail paragraph it sits beside is five sentences long; a command appended as a sixth is + a command nobody sees. Rejects the mutation that inlines the text into the surrounding `

`. + """ + command = _comments_only(_decl(RENDER, "openCommandNode")) + assert 'el("code")' in command + assert "keel open --port " in command + assert "keel open" in command + + +def test_the_command_survives_a_console_on_a_default_port() -> None: + """`location.port` is the EMPTY STRING on 80/443, so a naive build emits `keel open --port ` + with a dangling flag that the CLI rejects. The bare command is correct there -- `keel open` + carries its own default -- and the branch is what keeps it that way.""" + command = _comments_only(_decl(RENDER, "openCommandNode")) + assert "if (port)" in command + assert "else" in command + + +def test_the_paste_field_points_at_the_command_not_at_a_past_printing() -> None: + """ "Paste the address keel printed" is past tense about something that may be long gone -- + a daemon printed it to a log the plist says explicitly not to grep. The label must point at + something the operator can make happen NOW.""" + body = _refused() + assert "Paste the address from that command" in body + assert "Paste the address keel printed" not in body, ( + "the label is back to naming a printing the operator may have no way to reach" + ) + + +def test_the_instructions_precede_the_field_they_serve() -> None: + """Order is the whole affordance: the command PRODUCES the value the field consumes, so a + reader who meets the field first has already been asked for something they do not have.""" + body = _refused() + assert body.index("openCommandNode") < body.index('el("form")')