Skip to content

fix(cli): #891 -- headless sys.openUrl logs the setup URL instead of launching a host GUI browser - #895

Merged
jsavin merged 3 commits into
developfrom
unit-891-openurl
Aug 16, 2026
Merged

jsavin merged 3 commits into
developfrom
unit-891-openurl

Conversation

@jsavin

@jsavin jsavin commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Fixes #891.

Headless Frontier no longer launches a host GUI browser. The first-run onboarding intent is preserved — only the surface moves.

Why this is not "suppress the browser launch"

Legacy Frontier launched the owner's browser at the setupFrontier page to collect configuration (site name, admin credentials) from potentially non-technical server owners. That is a required product feature, and it is what the current goal means by "completes first-run."

The wrong part is narrow: a headless/server/CI process reaching into a host GUI session. There is no human at that machine's desktop, and the daemon host is frequently not the operator's workstation. So the headless default now logs the URL for a remote operator and records it for flows and tests; the launch itself is preserved behind an explicit opt-in and is unchanged in GUI builds.

Root cause: headless was the MORE aggressive path

webBrowser.openURL already branched on system.environment.isHeadless — but that branch was never a no-exec branch:

if system.environment.isHeadless {
	if not string.isValidUrl (s) { scriptError (...) };
	try { sys.openUrl (s); return (true)};
	msg ("Open this URL in your browser: " + s);
	return (false)};

sys.openUrl is the kernel verb that double-forks and calls execlp("open", ...). It succeeds, so the msg(...) fallback never runs and a browser opens. Headless routed through open(1) while interactive routed through an AppleEvent — the headless path was the more aggressive of the two. That inversion is the bug.

Trigger chain (this is how a browser actually launched during test cycles):

  1. finishInstall.ut:227 sets system.temp.installer.urlToOpen = setupUrl (its own openUrl call at :228 is commented out)
  2. startupScript.ut:252 calls webBrowser.openUrl (system.temp.installer.urlToOpen) at the next startup phase
  3. → sys.openUrl → execlp("open", ...) → browser opens on the host desktop

Where the guard goes, and a near-miss worth recording

The guard is at the sys.openUrl kernel verb — the single choke point every caller funnels through (webBrowser.openURL, trialVersionCheck, ftpTable, Frontier.tools.commands.openUrl, startupScript). A guard here cannot be bypassed by a script calling sys.openUrl directly, and it requires zero ODB edits, so no Virgin.root binary edit and no re-run of the #881/#892 repair risk.

The near-miss: the exec path was first traced to Common/source/shellsysverbs.c by reading the code. That file is not linked into frontier-cli (grep shellsysverbs frontier-cli/Makefile → no matches). The CLI's real implementation is tests/headless_sys_verbs.c (case sysv_openurl), pulled in via tests/headless_verbs.mk:47 → frontier-cli/Makefile:240 → :255 → :266. A fix landing only in shellsysverbs.c would have compiled, reviewed cleanly, and done nothing.

What caught it: the red test ran against the built binary and captured a real exec —

FAIL: default performs NO exec
  Exec recorded: open http://127.0.0.1:5336/setupFrontier

That line is the TU-identity proof. Both copies are now changed in lockstep, each carrying a TWIN: comment naming the other and stating which one is linked. Duplication tracked in #893.

Behavior (headless build)

--browser Behavior
absent (default) No exec. Log the URL, set system.temp.Frontier.pendingBrowserUrl, return true.
default Identical to absent.
agent-browser Unchanged — execs agent-browser (automated verification path).
allow-gui New. Restores the real open(1) / xdg-open launch for a local desktop-style session.
anything else Rejected with a scriptError, as before.

The opt-in is a new value on the existing --browser flag rather than a separate --allow-gui-browser flag: one flag gives one answer to "what does openUrl do here," where two flags could contradict each other. Usage text in frontier-cli/main.c documents all three modes.

GUI builds are unaffected by construction — the whole block sits inside FRONTIER_HEADLESS.

Marker semantics

system.temp.Frontier.pendingBrowserUrl holds the last URL that wanted a browser: written on every no-exec call, overwriting, with no URL sniffing. Unit B (the firstRootRun flag flip) asserts this equals the setup URL, which is a stronger assertion than a boolean would be. It is stored as a heap string rather than via the bigstring assign helpers, since a URL can exceed the 255-byte Pascal-string limit. A rejected URL (for example the empty string) never writes a marker.

Also fixed: a latent wrong Pascal length byte

shellsysverbs.c declared \x45 for a 76-character rejection message. Because that TU is not compiled by the CLI build, the headless static_assert never saw it. My twin edit initially repeated the mistake; the headless build caught mine, and I then verified the twin arithmetically. Both are now \x4c. The pre-commit hook confirms 1026/1026 Pascal lengths correct across 50 files.

Tests

tests/openurl_headless_guard_test.sh (new, wired in as test-openurl-guard in test-all and .PHONY).

Hard rule: no test may launch a host GUI browser. Exec assertions use PATH-shadowing stub launchers named open, xdg-open, and agent-browser that record their argv to a file. Since execlp resolves through PATH, the stub intercepts and the real browser is never reached — this is what lets the suite assert positively that the opt-in does exec.

Coverage: default returns true / performs no exec / sets the marker; explicit default behaves identically; marker overwrites per call; allow-gui execs with the URL and leaves the marker unset; agent-browser still execs; unknown values rejected with no exec; empty URL errors before any fork.

  • Red (pre-fix): 8 passed / 7 failed, including the Exec recorded: open ... capture above.
  • Green (post-fix): 15 passed / 0 failed.

Four integration cases added to sys_processor_tests.yaml, and two stale skip: "sys.openUrl not implemented" entries cleared — the verb is implemented, and those skips were hiding this path. One marker test explicitly clears the marker first, since sequential tests share one session (#852).

Suite evidence

This branch Baseline 2b56ce868
Integration total 2491 2488
Passed 2313 2308
Skipped 161 163
Failed 0 0
Unit exit 0 exit 0

Delta is exactly this change: +3 net tests, −2 stale skips.

Every anomaly was attributed against a purpose-built pristine baseline worktree (since removed) rather than assumed:

Follow-ups filed

Not in scope

No ODB edits — Virgin.root is byte-identical. No UserTalk script changes. GUI/Mac build behavior unchanged.

…ing a host GUI browser

Legacy Frontier launched the owner's browser at the setupFrontier page to
collect config from potentially non-technical server owners. That onboarding
intent is a required feature and is preserved. The only wrong part is a
headless/server/CI process reaching into a host GUI session: there is no human
at that machine's desktop, and the daemon host is frequently not the operator's
workstation.

Root cause: webBrowser.openURL already branched on system.environment.isHeadless,
but that branch was not a no-exec branch -- it called sys.openUrl, the kernel
verb that double-forks and execs open(1). It succeeded, so the msg() fallback
never ran and a browser opened. Headless was the MORE aggressive path, not the
safer one.

The guard goes in the kernel verb, the single choke point every caller funnels
through (webBrowser.openURL, trialVersionCheck, ftpTable, Frontier.tools.commands
.openUrl, and the live trigger startupScript.ut:252 -> system.temp.installer
.urlToOpen set by finishInstall). Guarding here cannot be bypassed by a script
calling sys.openUrl directly, and needs no ODB edit.

Headless behavior by --browser value:
  absent / "default"  -> no exec; log the URL and record it at
                         system.temp.Frontier.pendingBrowserUrl
  "agent-browser"     -> unchanged (automated verification path)
  "allow-gui"         -> NEW; restores the real launch for a local desktop session
  anything else       -> rejected, as before

The marker records the last URL that wanted a browser (overwrite per call, no URL
sniffing), so first-run flows assert the onboarding step by URL equality rather
than a boolean. GUI builds are unaffected: the headless block is inside
FRONTIER_HEADLESS.

Note on file layout: Common/source/shellsysverbs.c is NOT linked into frontier-cli
(absent from frontier-cli/Makefile); tests/headless_sys_verbs.c is the production
headless implementation despite living under tests/. Both carry the logic and are
changed in lockstep, each with a TWIN comment naming the other. Also corrects a
latent wrong Pascal length byte in the shellsysverbs.c rejection message.

Tests: tests/openurl_headless_guard_test.sh proves the default performs NO exec
and the opt-in DOES, using PATH-shadowing stub launchers so no real browser is
ever invoked. Registered as test-openurl-guard in test-all. Four integration cases
added and two stale "not implemented" skips cleared.

Unit: exit 0. Integration: 2491 total, 0 failed (baseline 2488/0; +3 tests,
-2 skips). Pre-existing and unchanged: 3 sys.appisrunning failures and the
load-dependent test_callback_infrastructure segfault (#828), both reproduced on
pristine 2b56ce8.
@jsavin

jsavin commented Aug 16, 2026

Copy link
Copy Markdown
Owner Author

Gate Review: unit-891-openurl (PR #895)

Verdict: PASS WITH NOTES — fix the P1 pair before merge (house completionist rule: P2s too)

Tests (orchestrator-run merge record at 1e776a7)

  • integration: PASS — 2491 total / 2313 passed / 0 failed / 161 skipped / 17 baselined, exit 0 (exact match with implementer's run)
  • unit: two orchestrator runs both terminated on the known Latent: in-memory hash node takes on-disk resolution path -> NULL deref (dbgeteof via hashresolvevalue) #828 crasher (test_callback_infrastructure "Empty callback" SIGSEGV) and nothing else — failure set matches the known-crasher baseline; implementer's run at the same SHA was a clean exit 0
  • new guard test: 15/15 (implementer-run; red pre-fix run demonstrated 7 failures incl. the literal Exec recorded: open http://127.0.0.1:5336/setupFrontier capture)

Reviewers

  • bar-raiser ✓ ran (always) — needs changes: 2 P1, 3 P2
  • security ✓ ran (always) — direction approved as strict narrowing: 2 P1, 1 P2
  • concurrency ✓ ran (auto: frontier-cli/** path trigger) — no P0: 1 P1, 2 P2

All three converge: the only blocking-tier defects are error-path memory-ownership bugs in the new setpendingbrowserurl helper. The design, the exec-surface narrowing, the twin mirror, and the test harness were each independently verified sound.


P1 — fix before merge (deduplicated)

# Finding File Flagged by Fix
1 Double-dispose on setheapvalue failure: its contract (langvalue.c:596 comment) disposes the handle itself on failure; the helper then calls disposehandle(hvalue) on the freed handle. Trigger: tmp-stack enlarge failure under memory pressure → heap corruption in a long-running server. tests/headless_sys_verbs.c:120-122 security, bar-raiser Delete the disposehandle(hvalue) on that branch; just return (false).
2 Double-free/UAF on hashtableassign failure: val is still on the tmp stack; the branch calls disposehandle(hvalue) without removing the entry, so later tmp-stack cleanup frees it again — decoupled from the failing call, non-deterministic under thread-globals swapping. Deterministically reachable from pure script: pre-create system.temp.Frontier.pendingBrowserUrl as a TABLE (hashassign's protect block rejects non-external-over-external), then call sys.openUrl in default mode. tests/headless_sys_verbs.c:125-128 concurrency, security, bar-raiser Replace disposehandle(hvalue) with disposetmpvalue(&val) (lang.h:1175 — removes from tmp stack and disposes; canonical). Keep exemptfromtmpstack after the successful assign (matches the documented tmp-stack anti-pattern guidance), and add a one-line comment that the assign→exempt pair must remain yield-free (concurrency's fragility note).

Shared root cause: hand-rolled disposal conflicting with the setheapvalue/tmp-stack ownership contracts. ~4 lines total, one function.

P2 — fix in the same round (completionist)

# Finding File Flagged by Fix
3 allow_gui_browser declared outside #ifdef FRONTIER_HEADLESS is set-but-unused in a GUI compile — breaks a future -Werror GUI build. Common/source/shellsysverbs.c (~:895) bar-raiser Scope the variable inside the ifdef; only use_agent_browser/fl_no_exec are needed outside.
4 Guard test runs read-write against the shared working DB (databases/Frontier.root); a dirty-flag propagation would make save_system_root_on_exit rewrite it every run → phantom md5 drift in staging checks. tests/openurl_headless_guard_test.sh:24 bar-raiser cp the DB into $STUBDIR and point --system-root at the copy (~3 lines).
5 log_info("…complete setup at %s") fires before flmarked is checked — log claims onboarding reached even when the marker write failed. tests/headless_sys_verbs.c:806-818 security Move the log after the flmarked check.
6 Twin comment claims "identical logic," but the classic TU never sets the marker — the observable contract diverges; next reader shouldn't trust "identical." Common/source/shellsysverbs.c:855-859, 946-955 concurrency One-line amendment stating the marker contract diverges (drift itself tracked in #893).

P2 — noted, no change required

  • log_info on this path relies wholly on the GIL for serialization (framework-wide property; must never migrate to a GIL-released region) — concurrency.
  • Theoretical settle-race in the guard script after unasserted exec runs; fix only if ever observed flaky (pattern-grep settle) — bar-raiser.

Clean dimensions (explicitly verified, not assumed)

  • Exec surface strictly NARROWED: default headless returns before any fork in both TUs; allow-gui restores exactly pre-fix behavior; URL passes as a single argv element (no shell); mode parsing is fail-closed with length-guarded memcmp (no over-read of non-NUL handles, no prefix confusion).
  • fork/exec child calls only async-signal-safe functions; default path no longer forks with the GIL held or blocks in waitpid; double-fork zombie reaping unchanged; no SIGCHLD handler exists to interfere.
  • Marker: write is GIL-atomic (no yield points in findnamedtable/tablenewsubtable/hashtableassign chain — verified by trace); overwrite disposes prior value (no per-call leak); system.temp carries dontsave so the marker can never reach disk; no rehash hazard (fixed-bucket tables).
  • Pascal length bytes verified arithmetically in BOTH TUs (76=\x4c); the pre-existing classic-file \x45 was a genuine 6-byte over-read, drive-by fix correct; PSTRING _Static_assert discipline is what caught it.
  • Test harness: PATH stub scoped to the subprocess env, mktemp -d + trap cleanup, cannot false-PASS the no-exec assertions (late writes only flip PASS→FAIL); covers all 5 modes × (return, exec, marker) + empty-URL + unknown-value.
  • All verb error branches unlock+dispose the URL handle (no leak); empty-URL rejection precedes mode logic (no marker on rejection, asserted by yaml).
  • Style, Makefile wiring, yaml metadata, cleared stale skips: all conform.

Stats

  • Files reviewed: 6 (+436/-22)
  • Findings: 0 P0 · 2 P1 (deduplicated from 5 reviewer reports of the same root cause) · 4 actionable P2 · 2 noted
  • Reviewers: 3 of 3 ran

Next step

Implementer fixes P1 #1-2 and P2 #3-6 in one round; re-run guard test + unit + integration at the new SHA; orchestrator re-runs the merge record and delta-reviews the changed function; then merge.

jsavin added 2 commits August 15, 2026 23:40
…rurl (P1-1, P1-2) + 4 P2s

P1-1 (headless_sys_verbs.c): setheapvalue disposes the handle itself on failure
(langvalue.c:594 -- "the caller isn't managing the memory any more"), so the
disposehandle on that branch was a double free. Removed.

P1-2 (headless_sys_verbs.c): on hashtableassign failure the value is still on the
tmp stack, so a bare disposehandle left a dangling tmp-stack entry. Replaced with
disposetmpvalue, which unstacks AND disposes. Documented that the assign->exempt
pair must stay yield-free.

P2-3 (shellsysverbs.c): allow_gui_browser scoped inside the FRONTIER_HEADLESS
block; it would be set-but-unused in a GUI compile. The fl_no_exec assignment
moved inside the same block so it can still read it.

P2-4 (openurl_headless_guard_test.sh): the test copies Frontier.root into its
scratch dir and points --system-root at the copy. The CLI opens the system root
read-write; these runs must not touch the shared working DB.

P2-5 (headless_sys_verbs.c): the setup-URL log_info now runs only after the
marker write succeeds, so the operator-facing line cannot claim onboarding was
reached when the marker was never recorded.

P2-6 (both TUs): TWIN comments no longer claim "identical logic". The classic
path suppresses the exec but does not log or set the marker, so the observable
contract diverges. Both sides now say so and reference #893.

Test note on P1-2: the review suggested pre-creating the marker path as a table
to force the assign-failure branch from script. Verified directly that this does
NOT fail in this build -- hashassign's protect block is gated on
fllanghashassignprotect, which headless_threadglobals.c:60 initializes to false
and whose only toggles (tableverbs.c:288/294) are commented out. The assign
succeeds and overwrites the table. Added a test asserting that actual observable
behavior instead of a failure that cannot occur; the P1-2 cleanup path is covered
by inspection against the langvalue.c/lang.h contracts.

Guard test 15/15. Integration 2492 total, 0 failed. Unit failure set = {#828}
only (test_callback_infrastructure 2/6 and test_window_bridge 1/6 clean standalone
runs; both nondeterministic, both unlinked from either file changed here --
verified test_window_bridge segfaults 4/5 on pristine 2b56ce8 too).
…s not reachable headless

The comment claimed the assign-failure branch was reachable from script by
pre-creating the marker path as a table, so hashassign would refuse a
non-external value over an external one. That refusal does not happen in this
build: it is gated on fllanghashassignprotect, which headless_threadglobals.c:60
initializes false and whose only toggles (tableverbs.c:288/294) are commented
out. Verified live -- the assign succeeds and overwrites the table.

The real headless trigger for that branch is allocation failure inside
hashassign (newhashnode). Comment corrected to say so, and to point at the
integration case covering the observed overwrite behavior.

Comment-only; no behavior change. Guard test 15/15; unit failure set = {#828}.
@jsavin
jsavin merged commit 6a9895d into develop Aug 16, 2026
1 check passed
@jsavin
jsavin deleted the unit-891-openurl branch August 16, 2026 06:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Headless webBrowser.openUrl should log setup URL for remote operators, not auto-launch a host GUI browser (preserve first-run onboarding intent)

1 participant