feat: epic #33 addendums 1 + 2 — one source of truth, truthful icon, in-app docs, native setup - #36
Merged
Merged
Conversation
New internal/config/schema.go: a Tunable per settable key carrying the label, kind, cap, unit, off-availability, help line, and doc anchor a surface needs to render a control for it without hardcoding anything of its own. Tunables(), TunableByKey(), TunableKeys(). The reason this exists is drift that had already happened. Every default was written down four times — the const block in config.go, the macOS app's placeholder hints, the tables in docs/usage/config.md, and the example configs — and they had come apart: the app advertised a 30s endpoint refresh and a 5s tunnel watch while the shipped defaults were 1m and 1s. Anyone reading the settings pane was being told the wrong thing. So a Tunable deliberately does NOT carry a hand-written default. Defaults() derives them from KeyValues() of a normalized Default() config, which IS the definition of "what you get if you set nothing". A metadata table that restated the numbers would just be a fifth copy. RestartReason is derived the same way, from restartReasonFor, so a key cannot read as live-appliable in the settings pane and restart-required in the reload report. Three defaults were still magic numbers inside Normalize — the endpoint refresh, the tunnel watch, and the starting blocklist — and those are exactly the two the app got wrong. Promoted to defaultEndpointRefresh, defaultTunnelWatch, and defaultBlockedCountries() so the const block is now genuinely the only place a default number is written down. Pure refactor; same values. CapKey names the tunable that bounds another rather than a number, because the ceiling is itself settable: a slider's top has to be read from the live config. A test asserts caps are non-transitive, since the three windows have separate caps precisely so widening one cannot widen another. Tests pin the table against the code rather than against itself: the Tunable set and KeyValues must be exactly equal in both directions (the same shape as the existing every-key-in-exactly-one-map test), the declared defaults must equal a normalized Default(), and every key marked Disablable must really survive Normalize as the negative sentinel — the promise that lets a surface offer an explicit "Off" is only honest if the setting is not silently discarded. No surface reads this yet; that is the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s
New read-only `dezhban config schema [--json]`: for every settable key, its label, default, cap, unit, whether "0" turns it off, whether a preset writes it, whether the daemon adopts it live, and which section of docs/usage/config.md covers it. It deliberately loads no config file. The schema is what the keys ARE, not what this host has set, so it answers identically on a machine that has never been configured — which is exactly when a first-run wizard needs to ask what a key means. The JSON shape embeds config.Tunable rather than restating its eleven fields in a parallel struct. That is the opposite of the convention changeJSON follows, and on purpose: config.Change has no json tags, so it would serialize capitalized, whereas Tunable was designed as the wire shape for surfaces and already carries the lowerCamelCase tags the app decodes. A second struct here would be precisely the extra copy this phase exists to delete. A test pins that the embedding flattens. The "Keys" section of `config --help` was itself a hand-maintained copy of the settable-key set. It is now generated from config.TunableKeys(), so a new key appears in the help by existing rather than by someone remembering. Tests assert the two key tables cannot diverge: configFields (what `config set` accepts) and Tunables (what surfaces advertise) must be exactly equal in both directions — a key in one and not the other is either a setting nobody can discover or one the app offers and the CLI rejects. Another cross-checks the two renderings of a value, since `config get` uses configFields.get while the schema's defaults come from KeyValues; on a default config they must agree exactly, or the app would show one default as a hint while `config get` reported another. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s
Defaults are data now, but documentation is prose, and prose is where the drift lived. Two tests make the config reference and the shipped examples answerable to the code. TestDocumentedDefaultsMatchTheCode parses every table in docs/usage/config.md and compares its Default column to the declared default, and also fails when a settable key has no row at all. Columns are found by header name rather than position because the tables really do differ (Field/Type/Default/Notes versus the advanced block's Field/Default/What it controls), and a heading sets the key prefix so the advanced table's bare `switchWindowMax` resolves. Durations are compared as durations, not strings: the tables read "30m" while Go's Duration.String renders "30m0s". Forcing the prose to say "30m0s" would make the documentation worse to serve the test, which is the wrong way round. Three keys genuinely cannot state a literal — the eight provider URLs do not fit a cell, control.socket documents the resolved path while the code default is "resolve it yourself", and control.group is platform-dependent. Those are exempt by name WITH a reason, and a stale exemption is itself a failure: if the cell later states the value exactly, the test says to remove it. A new prose default fails until somebody justifies it, rather than quietly opting out. TestExampleConfigsUseOnlyKnownKeys checks the examples are real: every key must still take effect, and the file must load and validate. It deliberately does NOT require values to equal defaults — an example exists to show non-default values. What must never happen is an example naming a key that silently does nothing, which is what a rename like reconnect→redial would otherwise leave behind. Two findings, both fixed here: windowProtocols and windowPorts shared one table row, so neither was individually checkable; they are now a row each, stating the literal `[]` default alongside the explanation of what empty means. Everything else already agreed — the drift was in the app's hints, which the next commit removes. Verified by injecting a wrong default and watching the test fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s
Settings now labels, explains, and hints every field from `config schema --json` rather than from literal strings in the view. This was a live bug, not just duplication. The hints were written by hand and had drifted: the pane offered "Endpoint refresh (e.g. 30s)" and "Tunnel watch (e.g. 5s)" for keys whose shipped defaults are 1m and 1s. Anyone reading Settings to learn what dezhban does was being told the wrong thing. The hints now say "(default 1m0s)" because they are the default, read from the daemon. New DezhbanCore/ConfigSchema.swift mirrors the daemon's shape, the same "Mirrors Go's ..." convention PresetInfo.swift uses, plus DezhbanCLI.readSchema(). It passes no --config on purpose: the schema describes the keys, not this host's values, so it answers before anything is configured — which is what a first-run surface will need. A CLI too old to know the subcommand yields no schema, and every use falls back to a plainer label that states no value. A missing schema must degrade to terse, never to wrong. SettingsFields now stores values KEYED by dotted key. It used to hold twenty-five properties destructured out of an array by literal index (values[0]...values[24]) behind a precondition on the count alone, so inserting a key anywhere but the end, or swapping two, would write one field's value under another field's key and pass every check. The named properties survive as computed accessors over the dictionary, so the SwiftUI bindings are untouched, but the failure mode is now unrepresentable. ConfigApply.seed returns a keyed map for the same reason: a positional result is only correct while every consumer agrees on the order, and that agreement was the fragile part. durationFieldsForValidation takes the schema and derives both which fields are durations and what they are called, replacing a hand-maintained list of thirteen labels that had to track the pane by memory. With no schema it validates nothing and lets the daemon reject a bad duration — guessing the field set would either miss one or invent labels that disagree with the ones on screen. vpn.pauseMax gains a control while the pane is being rebuilt: it was settable from the CLI and by editing the file, but the app offered no way to see how long a pause may last. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s
state.Snapshot gains Drop{at, cut}, set on a tunnel-down edge and
carried until a tunnel is up again.
The cut was unobservable on the common path. runGuard opens the
automatic redial window inside the same tunnel-down edge, so the
snapshot showing the guard holding a downed tunnel was superseded within
microseconds — and observers read the state file about once a second.
Publishing it in the right order alone would not have been enough:
nobody would ever have read that snapshot. So the drop is both published
BEFORE anything relaxes and carried across whatever follows, which is
what lets a surface say "your VPN dropped at 3:04PM" instead of only "a
window is open".
Carrying it does not make a window look like a cut. An open window is a
relaxation — traffic is flowing and the real IP may be exposed — so the
posture stays amber and Drop describes what happened, not what is
happening. `cut` is false when the drop occurred in standby, where
nothing was being enforced and claiming traffic was cut would be a lie.
The record clears the moment a tunnel is back, verified exit or not:
keeping it longer would leave both surfaces narrating an event that has
ended.
Two tests, both verified to fail without the change. One pins the
ordering (the first snapshot carrying a drop must precede the first
switch-window snapshot, and must itself read as guard) — with the early
publish removed it fails with both landing on the same snapshot, posture
"switch-window", which is exactly the bug. The other pins that the
record does not outlive the drop.
No surface reads the field yet; the renderer is next.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s
Every window sentence now leads with the exposure and when it ends,
rather than opening with the machinery and leaving the consequence
trailing behind a dash:
before: Guard relaxed so a new VPN can connect — your real IP may be
exposed until it closes (3:04PM).
after: Your real IP may be exposed until 3:04PM. The guard is
relaxed so a new VPN can connect.
An open window is the one state in which this tool is not protecting
the user. That fact should not be the second half of a subordinate
clause.
The redial sentence now names when the tunnel actually dropped, from
the record the previous commit began carrying: "Your VPN dropped at
3:03PM and the guard relaxed so it can redial." A guard holding a
downed tunnel names it too. The drop time is the one detail a user can
check against their own experience of the network dying, which is what
turns "a window is open" into an account of what happened.
Pause reads as a present condition rather than a risk — "You are using
your real IP at your request, until 3:04PM" — because during a pause
the exposure is the point, not a side effect.
Sentences are composed with joinSentences instead of dashes, so each
clause stands on its own and the drop time can be woven in or left out
without re-punctuating the rest. Both surfaces change together: the app
displays these strings and never composes its own.
Golden strings updated in the renderer table, the `switch --status`
tests, and the one example in cli.md. Two new table cases cover the
drop time being present, with a drop time deliberately a minute before
the window deadline so a case carrying both cannot pass by confusing
them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s
dezhban cannot tell a VPN you turned off from a VPN that fell over, so it treats every drop the same way and opens an automatic redial window. When you are the one disconnecting, that is a relaxation nobody asked for — and the reported confusion about the icon staying amber was really this: the tool was doing the right thing for the wrong drop. `dezhban hold` arms a one-shot flag; the next tunnel drop then stays cut, with the icon red because traffic really is cut. `hold --status` reports it, `hold --cancel` disarms it. New control ops hold-arm / hold-cancel, mirrored as command-file ops so it still works when no daemon answers the socket, plus state.HoldState on the snapshot. It only ever SUPPRESSES. The three sanctioned relaxation triggers are unchanged and there is no fourth, which is why this needs no ADR. It also carries no control.allow* gate, deliberately: every such gate exists to withhold an authority, this op grants none, and adding one would only hand an operator a way to switch off the safer behaviour. CLAUDE.md records both points, since the next person to read the three-triggers invariant needs to know why this is not a fourth. One-shot and un-persisted, three ways: spent inside maybeAutoWindow by the drop it covers, disarmed on a tunnel-up edge (the deliberate disconnect it was armed for is over), and gone on restart. An armed flag surviving a reboot would leave a later ACCIDENTAL drop with no redial help — the one failure this feature must never cause. The spend happens after the "is there even a window to suppress" checks but before the flap guard, so arming is only consumed when it actually prevented something. Arming while vpn.redialWindow is "0" is refused rather than reported as success: there is nothing to hold. Three tests. One arms and asserts no switch window is ever applied; one runs the identical fixture WITHOUT arming and asserts a window does open, so the first cannot pass because the fixture never dropped; one drives two drops and asserts the second opens a window, pinning that the arm does not outlive the drop it covered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s
The menubar gains "Hold the line — keep me cut", placed next to Pause so the pair reads as the two answers to the same situation: pause says let me use my real IP, hold the line says keep me cut. When it is armed, the item becomes "Don't hold the line" so the state and the way out of it are the same control. DezhbanCore learns to decode the two new snapshot fields — DropRecord and HoldState — plus a holdArmed convenience. Both are optional and additive, so a snapshot from an older daemon still decodes; absent reads as "not armed", never as a failure. Offered whenever the daemon is running, with no other gating: it can only ever suppress a relaxation, so there is no state in which arming it makes a host less protected. The red icon this makes reachable needed no new logic — PostureUI.guardHoldsDownedTunnel and dockState were already right, and already tested. What was missing was any way to REACH that state on the automatic path, which is what the daemon-side commit fixed. Tests cover the drop record surviving decode across a window (the one moment posture alone says nothing about the drop), a standby drop reading as not-a-cut, and both fields being absent. The window case asserts the drop time is earlier than both the window deadline and the snapshot time rather than pinning an epoch, so it fails if the two timestamps are ever confused for one another. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s
Pause lengths are now defined once in the config core (PauseOptions), so the CLI and the app offer the same set. `dezhban pause --list` prints them with what each is for, because the question a user actually has is never "how many seconds" but "how long do I need my real IP for". Any duration up to the cap still works; this is what gets offered, not what is allowed. Lengths above vpn.pauseMax are LISTED as unavailable with the cap named, not hidden. Hiding them teaches the user their cap is something other than it is, and they find out by bumping into it. The behaviour change: a pause longer than the cap is refused and explained rather than shortened to fit. clampPause used to hand back the cap, so asking for an hour against a 30m cap granted thirty minutes and reported success — indistinguishable, from the caller's side, from having got the hour. That is the same class of bug as accepting a disabled window and quietly restoring the default, which the config layer goes to considerable lengths to prevent. Refused in both places on purpose: the CLI refuses early with the cap named, and Options.pauseDuration refuses in the run loop, so a control-socket client that is not our CLI cannot reach the old behaviour either. A pause with NO duration given is deliberately unchanged and still clamped: nobody asked for a particular length, so there is no expectation to violate. TestReloadLowersThePauseCapImmediately previously asserted the clamping directly. It now asserts the refusal names the new cap, and follows up with a within-cap pause that must still succeed — so the test proves the cap bound, rather than that pausing broke. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s
The menubar's Pause item gains a submenu of the same lengths `dezhban pause --list` prints, read from the daemon via `pause --list --json` rather than listed in Swift — a picker with its own list would drift from the CLI the first time either changed. Clicking the parent still pauses for the built-in default; the submenu is for choosing deliberately. A length above vpn.pauseMax appears disabled with the cap as its tooltip. It is not hidden: a cap you cannot see is one you keep bumping into, and the previous behaviour — accepting the choice and quietly shortening it — was worse still. The list is read on the existing refresh, alongside status and profiles, so building the menu reads a cache. Shelling out while a menu is opening would stall it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s
Every duration field in Settings was a bare text field that required knowing Go's duration syntax, offered no idea what values were sensible, and gave its only feedback as a modal alert after Apply had been clicked. Each is now a menu of real lengths with a Custom entry that validates as you type. The choices are DERIVED, in DezhbanCore/DurationChoices, from the key's own default and its live cap — multiples of the default, plus the cap itself, with anything above the cap dropped. Nothing is listed per key. A new duration setting therefore gets a usable control by existing, and a lowered cap narrows the menu, because the ceiling is resolved against the values the pane is holding rather than a constant. Listing choices per key here would have re-created, one layer up, exactly the drift Phase M deleted. An explicit Off appears only where the schema says the "0" sentinel survives Normalize — the two windows, the pause cap, and the anti-flap gate — and states its consequence in words at the point of choosing. Offering Off anywhere else would present a security decision that silently does nothing, which is the worst bug this tool can have; the schema is what makes that judgement rather than a guess. DurationChoices carries its own Go-duration parser and renderer, tested for round-tripping, because a value the app writes has to read back identically from `config get`. Its isOff recognises all three spellings the round trip produces — the app writes "0", `config get` reports "0s", KeyValues renders "off" — since recognising only one would show a disabled setting as enabled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s
The pane's eleven sections followed the shape of the config file, so the order was VPN guard, Autodetection, Local network, Blocking, Windows, Timing — three of which are the same decision split three ways, and none of which said why you would touch them. Reordered to follow the decision: which VPN to trust, what gets blocked, when the guard may relax, local network, how closely it watches, then startup. Autodetection folds into "Your VPN" — those toggles were always about which tunnel to trust. Every section gains a line saying what it is for, which is the part a per-field tooltip cannot carry. Headings name the thing rather than the config block it lives in: "When the guard relaxes" instead of "Windows", "What gets blocked" instead of "Blocking", "How closely dezhban watches" instead of "Timing". That follows docs/concepts/glossary.md, which CLAUDE.md names as the authority for user-facing words, and the same rule that already retired "protection" and "egress blocked". Startup and updates moves below the guard settings and says explicitly that it acts immediately — it is the one group Apply does not touch, and the pane previously left the user to infer that from a doc comment they cannot see. The Advanced disclosure now says what its caps actually do, since lowering one visibly narrows the choices the settings above offer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s
The macOS app now bundles the repo's own documentation, rendered to HTML at build time. It works with every byte of egress cut — which is exactly when someone needs to know what dezhban is doing and cannot reach the web — and the pages always match the version that documents them, because they are built from that version's markdown. New internal/help holds the manifest and a small CommonMark subset renderer; tools/helpgen drives it and build-app.sh invokes it into Contents/Resources/help. Both are dev tooling, stdlib only, never installed and never on the enforcement path — the same standing as tools/taskmenu, and the reason no markdown library was added for a build-time step (CLAUDE.md's dependency rule). The renderer covers exactly what these docs use: headings, paragraphs, fenced code, lists, tables, quotes, rules, and inline links/code/ emphasis. The subset is ENFORCED rather than assumed — anything it cannot represent is reported and fails the build, so a page can never render silently mangled, and the docs stay inside a style the app can actually show. Escaping happens before markup substitution, so no document content can inject HTML. Documentation stays single-source: this renders the markdown, it never restates it. A listed page is load-bearing in the same way as a doc path cited from source, and CLAUDE.md now says so. Tests fail the build when a page is missing, when a page renders to nothing, and — the one that matters for the next commit — when a Tunable's DocAnchor names a heading that does not exist, so a contextual help link cannot rot into a silent no-op. A separate test asserts no rendered page reaches for anything off the machine. Nine pages ship: the four that make a tutorial track, plus the config, CLI, glossary, install and upgrade references. The ADRs and contributor docs deliberately do not — they are written for people working ON dezhban and would drown someone looking for how to unblock themselves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s
The docs have shipped inside the bundle since the previous commit; nothing displayed them. This adds the Help pane: sidebar over the bundled pages with the guided track first, substring search over titles, headings, and body text, and a WKWebView pointed at the rendered HTML. It never touches the network, which is the entire point. The moment someone most needs to know what the guard is doing to their traffic is often the moment it has cut all of it, so read access is granted to the help directory alone and the navigation delegate allows nothing but file: URLs inside it. A link that leaves the bundle is refused and reported with the URL, rather than silently doing nothing and reading as a broken app. Content JavaScript is off and the pane injects none either: a deep link's anchor rides in the URL fragment and WebKit does the scrolling. A link to another bundled page is handed back to the view, which moves its selection, so the sidebar never highlights a page the reader has left. AppState gains `helpTarget` and `openHelp(docAnchor:)` — the channel a contextual link from Settings will use next. A docAnchor whose heading no longer exists still opens the right page: a bad link beats a dead control. The new tests are written with swift-testing, like the rest of the suite. The first draft used XCTest, which split the runner in two — `swift test` then ran those nine and none of the other 78. All 87 run together again.
Each schema-driven control in Settings gains a ? that opens the Help pane at that key's own section, using the docAnchor the schema already carries. A tooltip holds one sentence; why a setting exists, what it costs, and what turning it off does often needs a page. The link lands on the heading rather than the top of a long reference, and a key whose anchor no longer resolves still opens the right page — Go's TestEveryTunableDocAnchorResolves is what keeps a stale anchor from shipping in the first place. With no schema (a CLI too old to know `config schema`) the button is absent rather than present and inert.
cmd/dezhban/setup.go held what the wizard asks, in what order, which answers unlock which follow-ups, and how answers become a config — all tangled into huh form construction, and reachable only through a TTY. internal/setup now owns the decisions; the CLI keeps the presentation and renders fields from the question set. That makes the wizard testable for the first time: pressing Enter through every question now provably lands on the config you started with, gating is checked without a terminal, and declining the VPN branch is pinned not to touch a VPN somebody already configured. It also exposes `dezhban setup --questions [--json]` — read-only, no root, no TTY — so a second wizard can render the same questions instead of keeping its own copy of them. That is what the macOS first-run wizard will read. Fixes a data-loss bug the extraction made obvious: applying the wizard ASSIGNED the imported profile list over the configured one, so re-running setup to change a log level, without naming those files again, silently deleted every profile a user had imported. Imported profiles now merge into the saved ones, replacing by name.
The macOS app's first launch on an unconfigured host now walks the setup questions natively. It reads them from `setup --questions --json` rather than holding its own copy, so the two wizards ask the same things in the same order with the same gating, and it saves through the same batched, validated `config set` as every other pane — no second write path, no second validator. Offered only when dezhban does not know a VPN yet: someone who set it up from the CLI has already answered these, and asking again would look like the app had forgotten. "Run Setup Again…" in Settings reopens it seeded with current values, which is the guided path for changing VPN. Two rules that are not derivable from a question's key, and that mirror Go's setup.Apply exactly, are pinned by tests: the free-text country codes fold into blockedCountries rather than writing a key of their own, and choosing automatic detection CLEARS pinned interfaces instead of skipping the key — a leftover pin is what stops autodetect from happening. Named VPN config files are imported after the config is saved, since a profile is not a config key. Cancelling that prompt loses the import, not the setup.
The dropdown is what you open to check whether you are exposed and how long is left. It now carries that and the actions that are urgent at that moment: the posture line, the switch or pause countdown, hold the line, and Open Dezhban. Block now and Unblock move to the window's Overview, where they already are. Somebody who wants to cut their own internet can turn off Wi-Fi; blocking by hand is a power-user and debugging affordance, not part of the routine flow. Panic moves behind ⌥ as the alternate to "Open Dezhban…" — the item swaps when Option is held, and ⌘⌥O fires it. It stays in the menubar because the moment it is needed is the moment the main window may not open, but it should not sit one slip away in a menu people open to read a countdown.
The bundled-docs design rests on one promise: a page the subset renderer
cannot represent fails the build. It was not implemented. Unsupported was
appended in exactly one place — an unclosed code fence — so everything
else degraded quietly and shipped wrong while every test passed.
What shipped: Quick start, the first page of the guided track, opened with
three lines of raw HTML source rendered as visible text. Bold that the
author soft-wrapped across two lines left literal ** in eight of the nine
pages. Nested bullets flattened into siblings, putting loose text directly
inside the <ul>. And an asterisk inside a code span (the glob in
`vpn.advanced.*`) paired with an unrelated one to open an <em> that closed
outside the </code>.
Two of those are reporting gaps and two are renderer bugs, so both halves
are fixed:
- Unsupported now names raw HTML blocks, nested list items, unpaired **,
and disallowed link schemes. A refused scheme is also replaced with "#"
so a javascript: href cannot reach the bundle even past a failed build.
- Blocks join their soft-wrapped lines before inline markup is read,
which is what CommonMark does and what this renderer's hard-wrapped
input requires. That fixes the straddling emphasis without rewriting
prose, and puts a list item's continuation inside its <li>.
- Code spans are lifted out to placeholders before the emphasis passes
and restored after. Wrapping them in <code> first never protected the
contents — the comment claiming it did was wrong.
- Bold is non-greedy rather than [^*]+, so **… *not* …** renders.
The tests could not have caught any of it: the subset test rendered a
synthetic sample of supported constructs, and the self-containment test
looked for src="http while the leaked tag was escaped. Both now run
against the real pages and assert on the rendered HTML, not only on the
reporting they exist to police.
The three pages the enforcement rejected are fixed too: the Quick start
banner is removed (it cannot render offline in a self-contained bundle),
a bold span in how-it-works is rewrapped, and the cli.md sidebar nesting
becomes its own flat list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s
Making the socket path refuse an over-cap pause left clampPause as a wrapper that called pauseDuration and threw the refusal away — so it returned 0 for a request that was merely too long, not disabled. Its one caller, the root-owned command file, then logged "pausing is disabled (vpn.pauseMax: \"0\")" while pauseMax was a perfectly real 30m. That path is not a backwater: it is what runs when no daemon answers the socket, and the only one when control.allowPauseOps is false. The CLI pre-validates with PauseRefusal, but only when its config loads and only against its own view of it, so a daemon that has since reloaded a lower cap still lands here. Before this branch the request was clamped and the pause opened; after it, the pause silently did not happen and the log blamed the wrong setting. It fails safe — no pause opens — so this is about the account the operator gets. That path has no reply channel, which makes the log line the whole explanation, so it now carries pauseDuration's refusal verbatim: which setting refused, and what to ask for instead. clampPause is deleted rather than fixed; with the refusal string it had nothing left to add, and its doc comment had become false on both of its claims. The two comments that named it are updated to name pauseDuration. Also corrects the hold-the-line comment in maybeAutoWindow: it claimed the flag is spent "whether or not anything else would have suppressed the window anyway", but the guard clause above it returns first for standby, FULL BLOCK, an open window, and a tunnel never seen up. The flag does survive those, and that is safe — a drop cannot follow a drop without an intervening tunnel-up edge, which disarms it — so the comment now says that instead of overstating the code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s
The autoDiscover question is macOS-only — Questions() does not append it on any other platform. NewAnswers therefore allocates no binding for it, Answers.Bool reports false for a missing binding, and Apply wrote that false straight onto vpn.autoDiscoverEndpoints. Re-running setup on Linux or Windows to change a log level turned off a setting the user had set, without asking and without saying so. This is the same failure that made setup delete imported profiles, one commit-series earlier: a value the user never touched, overwritten by a wizard that had nothing to say about it. The profiles fix merged; this one has to distinguish "answered false" from "never asked", which a bool cannot do — hence OptionalBool and a *bool on Input, with Apply writing only when the pointer is non-nil. The effect today is confined: the key does nothing off macOS. But GOOS is explicitly the platform the config is FOR, not the one running the wizard, so authoring a macOS config elsewhere is a supported path, and a config key being silently rewritten is worth fixing on its own terms. The regression test asserts through the real question set rather than a hand-built Input, so it fails if the question ever stops being gated the way it is — the shape of the bug, not just this instance of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s
…mponent Three small correctness fixes found alongside the larger ones. The drop record is carried until a tunnel comes back, which through an overnight outage or a long FULL BLOCK is not the same day. Rendering it as a bare time.Kitchen meant "Your VPN dropped at 3:04PM" read as a few minutes ago and understated how long the host had been cut — on the one screen whose whole job is saying what happened and when. The day is named as soon as the drop is not on the snapshot's own day, compared against s.Time rather than time.Now() so a Display still depends only on the Snapshot it was rendered from. HelpWebView confined navigation with hasPrefix on the readAccess path, so a sibling directory whose name merely starts the same way — "help-old" beside "help" — would satisfy it while being inside nothing. The bundle is generated and has no such sibling, which is the reason the check should not depend on that staying true. Compared by path component now. And the comment above allowsContentJavaScript said the coordinator scrolls via evaluateJavaScript, which its own implementation contradicts thirty lines below: the anchor rides in the URL fragment and WebKit does the scrolling, precisely so no script goes into a pane that has none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s
The bundled pages cross-reference the ADRs and the contributor docs thirty times, and those deliberately do not ship. rewriteLink returned an unmatched relative link unchanged, so "../adr/0008-arm-at-boot.md" reached the bundle verbatim and resolved beside it, where no such file exists. Clicking one reported "That link points outside the app: file:///…/Contents/Resources/adr/ 0008-arm-at-boot.md" — an internal path, on a Copy button that copied it. Every one of the nine pages had them, and the only link assertion covered the case that DID resolve. Render now takes the page's source path, because that is what a relative link means: "../adr/0001.md" is a different file in usage/config.md than in concepts/modes.md. Resolved against the repo root, a target that is a bundled page rewrites to the bundled copy as before, and anything else becomes a github.com URL — which the pane still cannot follow, but now reports as something a reader can use. Pinned to main rather than the built tag: this is the one link that is copied into a browser later, and a deleted tag reads as a broken project. TestEveryLinkGoesSomewhere builds the bundle and stats every local href, which is the assertion whose absence let thirty of them ship. Also closes the network guarantee the pane's own doc comment states. The navigation delegate sees navigations, not subresource loads, so it was never what would stop an <img src="https://…"> — only a grep in the bundle test was. The renderer now refuses a remote image by name, and each page declares a CSP of its own. `file:` is named beside 'self' there deliberately: a file: origin is opaque, so whether 'self' matches the page's own stylesheet is a WebKit detail, and a CSP that quietly dropped help.css would ship an unstyled pane — a worse regression than the one it prevents. Added to the on-host checklist, since only a real WKWebView can show it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s
DropRecord carried a Cut flag meaning "the guard was enforcing at that moment, so traffic really was cut". It was written by the runner, decoded by the app, and asserted on both sides — and read by nothing. That is not an oversight, it is the field resisting being used. Try to write the sentence it enables and there isn't one: "cut since 3:04PM" is false whenever a redial window opened and expired in between, which is the common path, and "not cut" is false the instant the window closes. Its only truthful reading is "cut at that microsecond", which no reader needs and no posture line can say. What a reader wants from a drop is WHEN; the posture fields already say what is happening now. It was also computed wrong. Cut: !standby claimed traffic was cut for a drop that happened inside an open switch window — which is precisely what a switch window is for, disconnecting one VPN to reach another. A correct !standby && !windowActive would have been a right answer to a question with no asker. The Swift test that pinned "a standby drop is not a cut" is replaced by one pinning that an unknown key does not fail the decode. The daemon and the app ship together but are not restarted together, so a running daemon can still be emitting `cut` after the app updates, and refusing the whole snapshot over a field this build no longer knows would blank the menubar until a restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s
Arming "hold the line" through the root-owned command file was a silent no-op when vpn.redialWindow was "0": nothing to suppress, nothing logged, no reply channel to refuse over. `dezhban hold` checks the config before writing — but skips that check when the file cannot be read, and then prints "hold the line armed" for something the daemon discards without a word. That is the same gap the pause path had until the commit before last, and the rule that fixed it applies unchanged: a channel with no reply owes the operator a log line, because the log is the only account they get. The socket path already refused this by name. The pause refusal itself now names the command that raises the cap, matching what config.PauseRefusal tells a CLI user before the request is ever sent. The two stay two sentences rather than one helper — internal/runner takes an Options and never a *config.Config, and that boundary is worth more than the duplication — so both ends now say so and point at each other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s
A pause's rendered sentence stated the same clock time twice in a row: "You are using your real IP at your request, until 3:04PM. The guard re-arms automatically at 3:04PM." pausedSentence and rearmSentence were each handed `until` and each said so, which reads as an unintentional echo rather than emphasis — the two facts (using the real IP now, and when that ends) only need stating once between them. pausedSentence no longer takes or states the until time; rearmSentence, which already runs immediately after it at the only call site, is left to say it once. Updated the two tests that pinned the old wording. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s
rewriteLink resolves a bundled page's relative links against the repo
root ("docs" + the page's own directory + the link target), which is
correct for the ordinary case of a link into docs/ or a sibling like
configs/. It did not bound how far a target's own "../" segments could
climb: enough of them turn the resolved path into one that climbs past
the repo root itself, which repoBase then turns into a URL pointing
outside the project — the same class of thing a disallowed scheme
(javascript:, data:) is already refused for by name, just reached
through path segments instead of a scheme prefix.
A path climbing that far isn't reachable from the docs as written today,
but nothing enforced that it couldn't be, and the existing rule here is
"refuse what cannot be represented safely, don't ship it and hope" — the
same rule the scheme allowlist and the remote-image check already follow
a few lines above.
TestRelativeLinkCannotClimbAboveTheRepoRoot pins the refusal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the two addendums of #33 in one branch: Phases I, J, K, L (Addendum 1)
and M, N, O (Addendum 2). 18 commits, each leaving the tree building and green,
with its docs and
## [Unreleased]entry in the same commit.They land together because they depend on each other. Phase M's tunable metadata
— key, default, cap, unit, whether Off applies, one-line help, doc anchor — is
exactly what Phase L's duration controls need to bound a slider and decide where
an explicit Off belongs, and what Phase K's contextual links need to know where a
setting is documented. Building L or K first would have hardcoded a third copy of
the defaults, which is the drift M exists to end. So M leads.
What changed
M — one source of truth for defaults
Defaults were written down in four places and had already come apart: the app
advertised a 30s endpoint refresh and a 5s tunnel watch while the shipped defaults
were 1m and 1s.
internal/config/schema.gonow declares all 38 tunables as data,deriving every default from
Default()itself;dezhban config schema [--json]exposes it; a test fails the build when
docs/usage/config.mdorconfigs/*.jsondisagree; and the app reads it instead of holding opinions.
I — the icon tells the truth
status --jsongains a durabledroprecord (at,cut), because the moment theguard cut a dropped tunnel was previously unobservable — the redial window opens on
the same edge, so the cut snapshot was superseded within microseconds while
observers read the file about once a second. Window sentences now lead with the
exposure and when it ends. And
dezhban holdkeeps a deliberate disconnectcut: it suppresses the automatic redial window for the next drop, one-shot,
un-persisted. It only ever removes a relaxation, so the three sanctioned triggers
are unchanged, there is no fourth, and it carries no
control.allow*gate —there is no authority to withhold.
O — pause presets
pause --list [--json]offers realistic lengths defined once in the config core andread by both surfaces. A pause over
vpn.pauseMaxis now refused and explainedrather than silently shortened (a
### Changedentry): granting 30 minutes whenan hour was asked for is indistinguishable from having got what you asked for.
L — settings that are choices, not syntax
Duration fields became menus derived from each key's real default and live cap,
with an explicit Off exactly where
"0"is a persisted opt-out.vpn.pauseMaxgets its first GUI control. The pane is reorganised around what you are deciding
rather than the shape of the config file.
K — the documentation ships inside the app
tools/helpgenrenders the repo's own markdown into the bundle at build time(stdlib only, no new dependency; the subset renderer is enforced by a test, so a
page it cannot show fails the build). The new Help pane reads it over
file://and refuses every other scheme — it has to work with every byte of egress cut,
which is exactly when it will be opened. Every setting carries a ? that lands
on that key's own heading.
J — setup without a terminal
The wizard's questions, ordering, gating, and application moved into
internal/setup; the CLI keeps only the huh presentation.setup --questions [--json](read-only, no root, no TTY) is how the new native first-run wizardin the app gets the same questions instead of keeping a copy. This also made the
wizard testable for the first time — including that pressing Enter through every
question lands on the config you started with.
N — the menubar becomes a glance
The dropdown carries the posture line, the live countdown, hold the line, Pause,
and Open Dezhban. Block/Unblock moved to the window's Overview (someone who wants
to cut their own internet can turn off Wi-Fi), and Panic sits behind ⌥ as the
alternate to "Open Dezhban…" — still in the menubar, because the moment it is
needed is the moment the window may not open.
Bugs found and fixed on the way
dezhban setupdeleted your saved VPN profiles. Applying thewizard assigned the imported profile list over the configured one, so running
setup again to change a log level, without naming those files again, silently
dropped every profile you had imported. Now merged, replacing by name.
SettingsFields.init(seeded:)destructured 25 values by literal index andchecked only the count — an ordering mistake would have written one field's
value under another field's key. Now keyed by config key.
swift testthen ran those and none of the other 78. All 97 run together now.Scope corrections
Items 32 and 33 (the
gui/artifactsrename, the Dock tile showing the brand icon)were already shipped in #35, and the stale
docs/modes.htmlreferenced in theepic no longer exists. Phase I is therefore items 34–36. Per the plan, Phase L is
items 42–43; the purpose-built list editors stay on the backlog.
Invariants
Untouched: the three sanctioned relaxation triggers and their separate caps,
hold-on-unknown, the tunnel+destination-scoped geo pass,
panic's independencefrom the daemon, and the single-goroutine
Backend.Applyrule. "Hold the line"suppresses and never relaxes, so it needs no ADR.
Verification
Behaviour preservation for the refactor commits:
print-rules(guard / fullblock /switch / legacy) and
validatediffed stdout and stderr against the pre-branchbinary across every config in
configs/— identical, modulo log timestamps.docs/contribute/testing.mdgains the privileged, on-host checks CI cannot run:hold-the-line through a real disconnect, the Help pane opened while FULL BLOCK is
active, the first-run wizard against the CLI wizard on the same host, and the
narrowed menubar with Panic behind ⌥.
🤖 Generated with Claude Code
https://claude.ai/code/session_0162UZtmTS7GMfGZHptRh52s