Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings remain in gateway shutdown, log handling, and configuration or diagnostic attribution.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This pull request improves profile-aware execution and display/input propagation while adding gateway logging, lifecycle controls, and egress diagnostics.
Changes:
- Routes host-run and profile windows to the correct display.
- Propagates keyboard layout, timezone, and gateway proxy settings.
- Adds gateway logs, status/stop commands, summaries, and doctor checks.
File summaries
| File | Reviewed change and final review note |
|---|---|
internal/util/xkb/xkb.go |
Selects keyboard layout based on the session type. |
internal/util/xkb/xkb_test.go |
Tests layout source preference and fallback. |
internal/util/tz/tz.go |
Resolves host timezone data for sandboxes. Final finding: nit (1 vote) on the malformed validation sentence. |
internal/util/tz/tz_test.go |
Tests timezone resolution. |
internal/runners/bwrap/testdata/supervised.golden |
Updates supervised Bubblewrap expectations. |
internal/runners/bwrap/testdata/plain.golden |
Updates plain Bubblewrap expectations. |
internal/runners/bwrap/testdata/hostnet.golden |
Updates host-network Bubblewrap expectations. |
internal/runners/bwrap/testdata/granted.golden |
Updates granted-access Bubblewrap expectations. |
internal/runners/bwrap/spec.go |
Adds timezone mounts and gateway proxy environment. |
internal/runners/bwrap/run.go |
Resolves timezone and proxy inputs for workloads. |
internal/runners/bwrap/run_test.go |
Tests updated sandbox inputs. |
internal/runners/bwrap/proxyenv_test.go |
Tests gateway proxy environment behavior. |
internal/profiles/profiles.go |
Propagates profile timezone and session settings. |
internal/profiles/profiles_test.go |
Tests profile environment propagation. |
internal/profiles/display.go |
Targets the profile display explicitly. |
internal/profiles/display_test.go |
Tests display environment and arguments. |
internal/gateway/summary.go |
Summarizes gateway decisions and errors. |
internal/gateway/summary_test.go |
Tests gateway summaries. |
internal/gateway/stop.go |
Implements gateway shutdown. Final finding: critical (1 vote) because teardown is not coordinated before restart. |
internal/gateway/stop_test.go |
Tests gateway stopping behavior. |
internal/gateway/status.go |
Reports gateway configuration and runtime state. |
internal/gateway/status_test.go |
Tests gateway status reporting. |
internal/gateway/run.go |
Redirects gateway output and records lifecycle state. |
internal/gateway/proxyaddr_test.go |
Tests proxy endpoint calculation. |
internal/gateway/logs.go |
Reads, filters, tails, and follows logs. Findings: critical (1 vote) for unbounded -n allocation, also at line 241; moderate (1 vote) for unbounded unterminated-line allocation, O(total lines × K) tailing, and missing retention limits; nit (1 vote) for the misleading missing-log error. |
internal/gateway/logs_test.go |
Tests gateway log handling. |
internal/gateway/logline.go |
Parses and filters gateway log fields. Final finding: moderate (1 vote) because quoted escape sequences are not decoded. |
internal/gateway/logline_test.go |
Tests log parsing and selectors. |
internal/gateway/attach.go |
Computes workload gateway proxy endpoints. |
internal/files/files.go |
Adds session gateway log paths. |
internal/doctor/session.go |
Adds gateway egress diagnostics. Final finding: moderate (1 vote) because ambiguous config resolution can suppress the active gateway checks. |
internal/doctor/session_test.go |
Tests session diagnostics. |
internal/doctor/environment_test.go |
Extends doctor environment support. |
internal/doctor/env.go |
Reads gateway log summaries. |
cmd/cli/root.go |
Registers gateway commands. |
cmd/cli/host_run.go |
Filters session variables and launches host commands asynchronously. Final finding: moderate (1 vote) because the Wayland session type is preserved instead of forcing X11. |
cmd/cli/host_run_test.go |
Tests host-run environment filtering. |
cmd/cli/gateway.go |
Implements gateway status, stop, and logs commands. Findings: moderate (3 votes) for falling back to unrelated user config, and moderate (1 vote) for silently omitting unresolved active profile links. |
cmd/cli/gateway_test.go |
Tests gateway configuration selection. |
Review details
Suppressed comments (11)
cmd/cli/gateway.go:106
- This silently discards an active profile whose config symlink cannot be resolved. If another profile resolves, the code can treat that one config as authoritative; if all fail, it reaches the unrelated fallback below. Treat any unresolved active link as unknown/ambiguous instead of omitting it, otherwise
gateway statuscan attribute the running gateway to the wrong config.
target, err := filepath.EvalSymlinks(path)
if err != nil {
continue
cmd/cli/host_run.go:49
hostRunEnvremovesWAYLAND_DISPLAY, but it preserves the launching session'sXDG_SESSION_TYPE=wayland. On a Wayland host this still lets GUI toolkits select the host Wayland backend and bypass the profile's X server; the profile environment itself explicitly forcesXDG_SESSION_TYPE=X11ininternal/profiles/profiles.go:861. Set the session type to X11 along withDISPLAYso host-run is consistently directed to the profile display.
return append(env,
"DISPLAY=:"+strconv.Itoa(int(display)),
"XAUTHORITY="+cookie,
)
internal/doctor/session.go:40
- This branch treats the caller's config as authoritative, but bare
doctorstill loads it throughprofileConfigOrDefault(""); when multiple active profiles come from different configs, that helper falls through to the user-level config. If that fallback has no gateway (or a different one), this early return suppresses the real session gateway and egress checks even when a gateway is running. The session check needs an explicit unknown/ambiguous-config path, or the CLI must use the same session-config resolution as gateway status.
checks := []Check{holder, gateway}
if gateway.Status == OK {
// Readiness and egress only say something once there is a
// gateway to ask about. Piling a second failure on top of the
// same cause would bury the one worth reading.
checks = append(checks, checkGatewayReady(env), checkGatewayEgress(env))
internal/gateway/logline.go:60
logValuerecognizes quoted slog fields but does not decode their escape sequences; it drops the backslash and copies only the following byte. An error such asmsg="dial failed\nretry"is therefore summarized asdial failednretry(and escaped tabs or other characters are also corrupted), sodoctorcan report the wrongLastError. Decode the complete quoted token with the same escaping rules as the slog/text format before returning it.
case '\\':
if i+1 < len(rest) {
i++
b.WriteByte(rest[i])
internal/gateway/logs.go:182
ReadStringreads and allocates the entire unterminated suffix before returningio.EOF, so themaxLineLencheck runs only after an arbitrarily large chunk has already been allocated. A long partial gateway line can therefore makegateway logs -fconsume unbounded memory despite the documented limit; read bounded fragments and retain an explicit oversized-line discard state across follow ticks.
for {
internal/gateway/logs.go:241
-fkeeps an open descriptor and leaves its reader at the old EOF. Truncating this same inode when a new gateway starts does not reset that reader, so a follow running acrossgateway stopand the next launch skips the new gateway's initial output until enough bytes pass the old offset (and may miss it entirely). The follower needs to detect truncation and seek/reset before reading the new log.
func openLog(path string) (*os.File, error) {
internal/gateway/logs.go:241
- The gateway sandbox writes through the descriptor installed as
cmd.Stdout/cmd.Stderr, while the uplink opens the same path withO_APPENDinappendLog. This descriptor is not append-only, so after the uplink appends a record the gateway's stale file offset can overwrite it and lose the very diagnostics this log is meant to preserve. Keep the initial truncation but also open this descriptor withO_APPENDso both writers append atomically.
func openLog(path string) (*os.File, error) {
internal/gateway/logs.go:124
- This is described as a ring, but it shifts every retained line for every input line.
gateway logs -n Ktherefore becomes O(total_lines*K), which can make a large session log very slow; use a circular index or queue so eviction is O(1).
if len(ring) == last {
ring = append(ring[:0], ring[1:]...)
internal/gateway/logs.go:70
- This error says the session never started a gateway, but a gateway started by an older qubesome version can be running without the new log;
doctoralready treats that as a missing-log case. The message should describe the unavailable log rather than incorrectly asserting that no gateway was started.
if errors.Is(err, os.ErrNotExist) {
internal/gateway/logs.go:224
- The session log is opened in append mode for the uplink and is only truncated when a new gateway starts; no size or retention bound is applied. A long-lived or busy browser session can therefore accumulate one line per proxy decision until it consumes the user's run-directory disk space. Add a bounded/rotated log policy (or another explicit retention limit) before making this the persistent gateway output.
// explaining a gateway that had trouble coming up.
func appendLog(path string) (*os.File, error) {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, files.FileMode)
if err != nil {
internal/util/tz/tz.go:29
- The sentence "A path that does not match names no zone" is grammatically malformed and obscures the validation rule. Rephrase it to state that a path which does not match a zone name does not name a zone.
- Files reviewed: 42/42 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
🔵 Needs a closer look
Six moderate findings remain unresolved across gateway status, host-run isolation, log following, gateway cleanup, and timezone mounting.
Review details
Suppressed comments (6)
Previously missed (1) — in code that hasn't changed since the last review.
cmd/cli/host_run.go:48
- Removing
WAYLAND_DISPLAYalone does not prevent a Wayland client from connecting to the host compositor: libwayland falls back to$XDG_RUNTIME_DIR/wayland-0when the variable is unset, and this function deliberately preserves bothXDG_RUNTIME_DIRand the hostXDG_SESSION_TYPE. On a Wayland host, a GTK/Qt command can therefore still ignoreDISPLAY=:...and open on the host desktop instead of the profile. Force the launched command onto X11 and make the host Wayland socket unavailable in this environment.
cmd/cli/gateway.go:126
- This fallback guesses the user-level config whenever no active config symlink can be resolved. Ordinary local starts do not create an entry in
activeConfigs, and a gateway also outlives the profile that started it, sogateway statuscan report the user config's image, policy, and subnet for a gateway started from a project or Git config. Persist the source config with the gateway when it starts, or report the configuration as unknown instead of inferring it here.
// No profile running, or one whose config no longer reads. The
// user-level file is the only thing left that describes a gateway.
return profileConfigOrDefault(""), ""
internal/gateway/logs.go:109
bufio.Scannerreturns the final unterminated token as a line. If the gateway is writing a record while the initial read runs, this emits only the prefix and leavesfollowat EOF; the remainder is then treated as a new line, so filters can split or permanently miss that record. Preserve an unterminated tail and combine it with the next read, asfollowOncealready does.
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, bufio.MaxScanTokenSize), maxLineLen)
for scanner.Scan() {
internal/gateway/logs.go:170
- Follow keeps one file descriptor and its offset for the entire command, but restarting the gateway truncates this same log path with
O_TRUNCand writes the new log from offset 0. The follower then remains past the new EOF and skips the new gateway's initial records (only resuming after the file grows beyond the old offset). Detect truncation and reset/reopen the reader when the file shrinks.
func follow(ctx context.Context, w io.Writer, f *os.File, selects func(string) bool) error {
ticker := time.NewTicker(followInterval)
defer ticker.Stop()
reader := bufio.NewReader(f)
var pending strings.Builder
for {
select {
case <-ctx.Done():
// The only way a follow ends is by being asked to stop, so
// that is not an error to report.
return nil
case <-ticker.C:
if err := followOnce(w, reader, &pending, selects); err != nil {
internal/gateway/stop.go:86
waitGoneonly observes the inner sandbox PID; thereapgoroutine still waits for the outer bwrap and then unconditionally removes this shared state path. Once this method removes the record and releases the lock, a replacement gateway can start and write a new record before the old reaper runs, after which the old reaper deletes the replacement's state. Coordinate with the outer-process reaper or make cleanup conditional on the PID/start time that it originally recorded.
if err := waitGone(g.StatePath, stopGrace, stopPoll); err != nil {
return st.PID, err
}
// A qubesome run at a terminal exits long before the gateway it started
internal/util/tz/tz.go:47
SandboxPathis a nested file under/usr/share/zoneinfo, but the workload and profile mount builders pass it directly to bubblewrap without creating the destination. An image without the host's exact zone file (common for minimal images) makes the bind fail and prevents the sandbox from starting, so the new timezone propagation does not work for those images. Create the destination in the sandbox setup or mount the host file at a guaranteed existing path before settingTZ.
- Files reviewed: 42/42 changed files
- Comments generated: 0 new
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings remain in tunnel output, sandbox mounts, gateway state, and diagnostics.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (8)
Previously missed (1) — in code that hasn't changed since the last review.
cmd/cli/gateway.go:114
sessionConfigtreats the currently active profile config as the source of the already-running session gateway, butGateway.Upreuses an existing gateway andreloadonly asks that process to reread its already-mounted policy; it never replaces the image or policy mount. If gateway A is running and a profile from config B remains active, status reports B's image, policy, and subnet for gateway A. Persist the gateway's source configuration when starting it and read that record here, or report the source as unknown when it cannot be established.
cmd/cli/gateway.go:126
- This fallback is not authoritative when no profile is active, or when the active profile's config can no longer be read: a session gateway can outlive the profile that started it, so the user-level config may describe a different image, policy, and subnet. Returning that config makes
gateway statusclaim details about a gateway it did not come from. Use a persisted gateway source record, otherwise leave the configuration unknown.
// No profile running, or one whose config no longer reads. The
// user-level file is the only thing left that describes a gateway.
return profileConfigOrDefault(""), ""
internal/doctor/session.go:212
- When a log contains both a gateway error and policy denials, this early return drops the denied count and host names entirely. A denial is still the useful explanation for a workload that cannot reach a host, even when an unrelated error also occurred; the documented egress report should retain both facts (with an overall warning for the error) instead of forcing the user to reconstruct the denials from the raw log.
if summary.Errors > 0 {
return Check{
Name: name,
Status: Warn,
Detail: fmt.Sprintf("the gateway reported %s, most recently %q",
plural(summary.Errors, "error"), summary.LastError),
Fix: "Read `qubesome gateway logs` for the whole of it. `-workload` and `-profile` " +
"narrow it to one workload.",
}
internal/gateway/logs.go:289
-ftreats any nondecreasing file size as the same log. If a replacement gateway truncates this path and writes at least the old size before the next 200 ms poll, the reader stays at the old offset and skips the beginning of the new gateway's log, including early decisions. Detect the truncation/restart using file metadata or reopen/reset the reader rather than relying only onfi.Size() < size.
if fi.Size() >= size {
return false, nil
internal/gateway/stop.go:89
- Because
waitGonetreats a zombie as exited, this removal can happen before the profile process that launched the gateway has reaped its outer bwrap and itsreapgoroutine. If a new launch writes a new gateway state afterStopreturns, the old reaper unconditionally removes the same path and erases the new gateway's record. Coordinate the outer reaper or make state removal conditional on the recorded PID/start time before removing it here.
if err := os.Remove(g.StatePath); err != nil && !errors.Is(err, os.ErrNotExist) {
internal/gateway/tunnel.go:110
- CONNECT establishes a tunnel for any 2xx response, not only
200 OK. A proxy returning another successful status (for example 201 or 204) is treated here as a policy refusal, so SSH is rejected even though the tunnel was established.
if resp.StatusCode != http.StatusOK {
internal/profiles/profiles.go:776
- This host-zone mount is unconditional even when
profile.Timezoneis set. A profile configured for a zone such asUTCcan therefore fail because the image lacks the host's zone file/path, although that file is not needed for the configured timezone. Only add the host zone mount when the profile timezone is empty.
internal/runners/bwrap/spec.go:373 - This host-zone mount is unconditional even when
profile.Timezoneoverrides the host. A workload using a configured zone can fail because its image lacks the host's zone file/path, despite never using this mount. Guard the mount onprofile.Timezone == ""(and still handle missing destination parents).
- Files reviewed: 48/48 changed files
- Comments generated: 4
- Review effort level: Lite
A profile stopped starting after a bubblewrap upgrade:
bwrap: Can't mount on symlink destination /etc/localtime
0.12.0 rewrote sandbox setup to resolve paths with
openat2(RESOLVE_IN_ROOT), fixing GHSA-pxhw-h44j-8pfx, and added with it
an unconditional refusal to mount on a destination that is a symlink. An
image ships /etc/localtime as one, so the bind qubesome has always asked
for is now fatal, for a workload as much as for a profile. Up to 0.11.x
ensure_file() tested the destination with stat(), which follows links,
so the mount landed on the image's link target instead and nobody had to
think about it. There is no opting out: --not-a-security-boundary only
reaches BIND_FAIL_OPEN, which is applied after the die.
So nothing is mounted on /etc/localtime any more. internal/util/tz
resolves the host's zone file, shares it under the name every image
knows it by, and TZ points the sandbox at it. That is the mechanism the
workload runner already used for profile.Timezone, and it carries the
zone name as well as the offsets, which following a link into the image
never did: a host on Europe/London used to reach a sandbox still calling
itself Etc/UTC. A host that copied its zone file into place rather than
linking to one has no name to carry, and keeps its offsets under one of
qubesome's own.
The cost is that /etc/localtime inside a sandbox is now the image's own,
so anything reading it while ignoring TZ sees the image's zone. Every C
library reads TZ ahead of that file, and so do the runtimes that resolve
a zone themselves rather than through one.
profile.Timezone now also reaches the profile sandbox, so the window
manager's own clock follows it rather than the host.
Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Paulo Gomes <paulo@entire.io>
Entire-Checkpoint: 01M253CGA46V3B5FSJJDY0E4AN
qubesome host-run built the command's environment as exactly one entry,
DISPLAY, so it reached the profile's display with no cookie and no HOME
to find one under. The display is served by an Xwayland started with
-auth, which refuses a connection that arrives without one:
Authorization required, but no authorization protocol specified
The socket was never the problem. A profile bind-mounts the host's
/tmp/.X11-unix read-write, so Xwayland's socket has always been on the
host, under bwrap as much as under the container runners before it. Only
the cookie was missing, and it has been missing since the command was
added: nothing qubesome did ever authorized it. A profile whose dotfiles
loosened access control from the inside, with an xhost +local: or
+si:localuser:, would have papered over that, which is the likely reason
it used to work.
The client cookie is the one passed rather than the server's. Both carry
the same value, but the client copy is written with the wildcard family,
so it matches whatever the host currently calls itself, while the server
copy names the hostname captured when the profile started.
The host environment is now inherited rather than replaced. The command
runs on the host with the user's own privileges, so withholding HOME and
PATH from it isolates nothing and only stops it finding its own
configuration. WAYLAND_DISPLAY is dropped on the way through, because a
toolkit that finds one ignores DISPLAY, and the window would open on the
host desktop instead of in the profile.
Assisted-by: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Paulo Gomes <paulo@entire.io>
Entire-Checkpoint: 01M25CV60KZCHXZCG7YG7VR27N
host-run waited on the command through CombinedOutput, so it held the terminal it was typed at until the application exited, and showed nothing until then. Launching an application into a profile is what the command is for, so that wait was its whole cost. It now starts the command and returns, as launching a workload does. Its stdout and stderr stay pointed at the terminal, because a command that cannot reach the profile's display says why there, and its stdin is left closed rather than pointed at a terminal the shell has taken back. The exit status now reports the launch rather than the application: a failure after it starts arrives as output, once the prompt is already back. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Paulo Gomes <paulo@entire.io> Entire-Checkpoint: 01M25CVVJAM0PW6AGGC9FAFSGB
The gateway's stdout and stderr were this process's own. It is started by whichever qubesome run found none already running, and put in a session of its own so that a Ctrl-C at that terminal does not take the session's egress away with it, so what it says went to a terminal nobody is necessarily still watching, interleaved with the output of the workload that happened to start it. The audit line behind a refused connection is the one thing needed when a workload cannot reach something, and there was nowhere to go and read it. The gateway sandbox and its uplink now write to a log in the session directory instead. The sandbox's launch truncates it and the uplink, which is started once that sandbox is up, appends, so one file describes the gateway that is running, in order. It is 0600: it holds every name a workload asked for and every host it was allowed or refused, which is a record of what the user was doing. Truncating on launch is why there is no rotation. The log belongs to one gateway rather than to the session, and a gateway is only ever replaced by the first one having gone. `qubesome gateway logs` reads it back, with -n for the last lines and -f to keep printing what is added. A follow is a poll, since the sandbox writes to the file through a descriptor and nothing notifies a reader of it, and it copies in bounded steps so a chatty gateway cannot make the reader hold the whole log. A session that never started a gateway has no log, and saying so is the answer to what was asked rather than a failure to answer it. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Paulo Gomes <paulo@entire.io> Entire-Checkpoint: 01M25TRK55EZ9D3Q8SB0701QFB
An application launched with host-run opened on the profile's default workspace rather than the one being looked at. host-run used to give the command DISPLAY and nothing else. It now hands over the whole host environment, which is right for HOME and PATH and wrong for the handful of variables that name the session the command was typed or bound in, because the session it is about to appear in is a different one. DESKTOP_STARTUP_ID is the one that moved the window. A window manager that spawns through startup notification records the workspace it spawned from against the id it exports, and the application passes that id to the next window it opens as _NET_STARTUP_ID. The profile's window manager then reads an id for a launch it never saw and places the window by whatever that resolves to. It is single use as well: it belongs to the launch that created it and to no later one, which is why awesome unsets it itself when it spawns without a context of its own. XDG_ACTIVATION_TOKEN is the Wayland spelling of the same thing and goes with it. WAYLAND_DISPLAY was already being dropped, for a reason that is the same one told differently, so the three are now one list with the reason written once. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Paulo Gomes <paulo@entire.io> Entire-Checkpoint: 01M26DCGXTE5NCSVHSXA82MPNR
A profile came up on a layout its user does not type on, again. Asking localectl first was right for the host it was written for and wrong for the one it broke. The two tools disagree more often than they look like they should, and which of them to believe depends on where the question is asked, not on a preference between them. On X11 the running answer wins. setxkbmap asks the X server the user is typing on, which includes a layout applied by hand after login, and that is the layout the profile should come up on. localectl reports what was configured, which on a host whose layout is set at runtime is a layout nobody is using. On Wayland the configured answer wins, which is what the previous behaviour was reaching for. There setxkbmap reaches Xwayland, which carries its own default rather than the compositor's keymap, so it answers confidently with nobody's layout. An unset session type is treated as X11: every X11 session sets it, and a session that sets nothing is not a Wayland one. XKB_DEFAULT_ still wins over both. The two tests that asserted the old order called firstOf directly with a fixed order, under names describing a policy that is now session dependent. What replaces them drives the decision itself, on both sessions, and covers the fallback in both directions. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Paulo Gomes <paulo@entire.io> Entire-Checkpoint: 01M26DERTN4KQDGNQD5BQZFBA4
The window manager was given the profile's cookie and had WAYLAND_DISPLAY taken away from it, and was then left to whatever DISPLAY xwayland-run exports. The profile container's own DISPLAY is the host's, because that is what the compositor presents through, and the whole of /tmp/.X11-unix is mounted so that its socket is reachable. Anything under the window manager that inherited that would be talking to the host session rather than to the profile. It is named here instead, beside the two that were already being set, so nothing in that subtree can reach the host's X server by inheriting a path to it. Workloads were never exposed this way: each mounts only its own socket rather than the directory. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Paulo Gomes <paulo@entire.io> Entire-Checkpoint: 01M26DGJC18YYEP4XZ3G07QR7R
A session gateway serves every workload of every profile, so its log is every decision made for all of them interleaved. Finding why one workload could not reach something meant reading past everything else. -profile and -workload narrow it. The gateway knows a workload only by the name qubesome registered, which is the workload's own name and its profile's joined by a dash, and either half may hold a dash of its own, so the pair cannot be split back into two names with any certainty. Naming both is therefore the exact question and naming one alone is a prefix or a suffix of it, which is spelled out in the command's help rather than left to surprise someone. -n now counts the lines it shows rather than the lines in the file. Asking for one workload's last twenty otherwise gives however few of its lines happen to fall in the file's last twenty. Reading the log is line oriented rather than a byte copy, which is what a filter needs. A follow holds a line back until it has a newline on it: a line is what carries the workload a decision was about, so half of one cannot be matched, and printing it unmatched would show another workload's log to someone who asked not to see it. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Paulo Gomes <paulo@entire.io> Entire-Checkpoint: 01M26DJVEWJG0V1SSAY0QJD1CM
doctor said whether the gateway was running and whether it was ready, and nothing about the thing it exists to do. A workload that cannot reach something got a clean bill of health and no hint of where to look. The new check separates two findings that are not the same. A refused connection is the policy working, so it is reported at ok, with the count and the hosts named, because "why can I not reach this" is what brings someone to doctor and the answer is a host name. An error is something the gateway tried to do and could not, so it warns and names the most recent one, which is where a splice that failed to dial or an original destination it could not recover now shows up. The log is summarised in internal/gateway rather than read here, so the one place that already understands the gateway's log format is the only place that parses it. It parses tolerantly: the format belongs to a component on its own release cycle, so a line that cannot be read contributes nothing rather than failing the check. The readiness check's advice was to read the gateway's own output, which until recently went to a terminal that was usually gone. It names the command now. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Paulo Gomes <paulo@entire.io> Entire-Checkpoint: 01M26DMN1PHXHQET4H4S6H5R4Y
gateway status read its config through profileConfigOrDefault(""), which
takes an active profile's config only when exactly one profile is active
and otherwise falls back to the user-level file. The gateway is session
wide: it was started by whichever launch found none running, from that
profile's config, and may have come from any of the active ones. With more
than one active, status could therefore name an image, a policy and a
subnet the running gateway has nothing to do with, and say nothing to
suggest it was guessing. Raised in review on #226.
Several active profiles are usually not an ambiguity at all. One qubesome
config commonly defines several profiles, so the run dir holds several
symlinks to one file, and resolving them leaves a single config that is
the answer whichever profile started the gateway. That is now what is
looked for.
It is only profiles started from genuinely different files that leave
nothing here able to say which the gateway came from, and status now says
that instead of picking one. ConfigProblem already exists for exactly this
shape of answer, and reporting that it cannot be told is the behaviour the
command was written around: it reports a reason rather than leaving the
lines blank.
What this does not do is say what the running gateway is actually running,
as opposed to what a config names. Only the gateway's own launch knows
that, and nothing records it. The allocation record already carries the
subnet the running gateway hands addresses out of, and is already compared
against the config, so it is where the image and the policy path would go
if that becomes worth having.
Assisted-by: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Paulo Gomes <paulo@entire.io>
Entire-Checkpoint: 01M26F8MJDW9GE2ZX2Z8XKNECR
The gateway drops every port but 80, 443 and 53, so a workload reaches
anything else by asking it for a tunnel instead of connecting out. Asking
means naming an endpoint, and nothing in the sandbox said what it was.
The gateway's own address a workload could have worked out for itself: it
is already its default route and the nameserver in its resolv.conf. The
port it could not, because that one belongs to the gateway image. So what
it is told is the pair, in QUBESOME_GATEWAY_PROXY, ready to be used as it
stands. An ssh client reaches a host it is allowed to reach with
ProxyCommand socat - PROXY:$QUBESOME_GATEWAY_PROXY:%h:%p
and nothing there names a port that is the gateway's business to choose.
The port is a const beside the gateway image's other paths, and carries
the same caveat they do: it belongs to the image, so the two have to be
changed together. Telling a workload the whole endpoint rather than only
the address is what keeps that caveat out of anybody's dotfiles.
A workload with no gateway is told nothing rather than told an empty
value, which would read as an endpoint that exists and is nothing.
Assisted-by: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Paulo Gomes <paulo@entire.io>
Entire-Checkpoint: 01M26FTJHYYKTQB58PQ5NEECRV
CodeQL: a writable handle from OpenFile closed without handling the error, where a failure can mean data that never reached the disk. Nothing here writes through this descriptor. It is opened, handed to the sandbox and to the uplink, which get their own from Start onwards, and qubesome's copy is closed as being of no further use. So unlike the write in replace there is no last part of one arriving at close, and nothing to lose by not looking. Discarding the error is still the wrong shape. A close that fails says the filesystem holding the log is unwell, and the log is the file a gateway that goes wrong explains itself in, so it is worth a line. It is a warning rather than an error because the gateway is already running by then, and a log qubesome could not close is no reason to take one down. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Paulo Gomes <paulo@entire.io> Entire-Checkpoint: 01M26P1R6XJFJ64N08Y768H75Q
Stop signalled the sandbox and removed its record in the same breath. A delivered signal is not a sandbox that has gone, so the next launch could take the lock, find no record, and start a replacement while the old pid namespace, its outer bwrap and its uplink were still coming down. Raised in review on #226. It now waits for the record to stop naming something running, and only then removes it. The record has to outlive the wait, because it is what the next launch decides by. Waiting is a poll, since there is nothing to wait on: a gateway is not the child of whatever stops it, so it cannot be reaped here and the kernel will not report its going. Five seconds bounds it, which is a ceiling on the kernel finishing rather than an estimate: SIGKILL to pid 1 of a pid namespace takes the namespace with it and is not something the process can put off. sandbox.Exited is new and is not the negation of Alive. A process killed by something that is not its parent stays in the table as a zombie, with its /proc entry and its start time intact, so Alive reads it as running when it is only waiting to be collected. The parent that would collect it is usually a qubesome run that exited at its terminal long ago, so a wait on Alive would have waited out the whole grace every time. It reads the state character from the same stat line the start time already comes from. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Paulo Gomes <paulo@entire.io> Entire-Checkpoint: 01M26PCGB8A1QDXH7RYXWA20XB
The subnet mismatch reported "the running gateway hands addresses out of X", which is false in the state it is most likely to be read in. The record outlives the gateway that wrote it, and gateway stop leaves exactly that behind: nothing running, and a note of the range the last one handed addresses out of. The test covering it had no gateway running either. Raised in review on #226. It now describes the record, and says what to do about it, which is the same remedy a launch is given when it refuses the same mismatch. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Paulo Gomes <paulo@entire.io> Entire-Checkpoint: 01M26PD3GTGSJB8AR4615X32WN
A launch and a status report the same mismatch, and said it differently. The status one was reworded to describe the record rather than a gateway, because a status is read in the state gateway stop leaves: nothing running, and a note of the range the last one used. This one opens the same way now, so the two are recognisably about the same thing. What it keeps is the claim the status message had to drop. Allocate is only ever reached through Attached, after Up, so a gateway is running by the time this is read, and its holding the first address of the old range is the reason the remedy is a restart rather than an edit. Dropping that here would have made the message consistent and less useful. The message had no test. It has one now, since it is the whole of what a user is told when a subnet changes under a session that has already handed addresses out. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Paulo Gomes <paulo@entire.io> Entire-Checkpoint: 01M26PK5W39Z73BV7D6PJNJTNG
Some commands have a caller reading their standard output as data rather than as text for a person, and a diagnostic written there is read as that data. tunnel, added next, puts an ssh connection through stdout: a refusal printed to it reached ssh as the far end's first bytes, and ssh reported "Connection closed by UNKNOWN port 65535" rather than the reason the gateway gave. Nothing is lost for the commands whose output is only ever read by a person. An error was never part of what they were asked for. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Paulo Gomes <paulo@entire.io> Claude-Session: https://claude.ai/code/session_01FyyRXjuCLgApR4jtCCSuHK Entire-Checkpoint: 01M2A4M3FYMMFH0V2JKKWVR7KX
QUBESOME_GATEWAY_PROXY told a workload where to ask for a tunnel, and nothing read it. ssh does not read the environment, so it went on dialling port 22 straight out, which the gateway's forward hook drops, and every ssh from a workload ended in a connection timing out. Two things were missing between the variable and a working ssh. The first is something to speak CONNECT. The recipe this variable was added for names socat, which no workload image ships, so it could not have run even where it was written down. The qubesome binary is already bound into any sandbox attached to a gateway, having been given it to be wired up at all, so tunnel is a subcommand rather than a dependency. Keeping the exchange here also means it can be tested, rather than living in a config string nothing reads until an ssh fails. The second is telling ssh to use it. The drop-in goes to the directory the system ssh_config already includes from, so no image changes and nothing of an image's is covered over, and a workload's own ~/.ssh/config is still read first and still wins. It is written per launch and mounted only when there is a gateway, keyed on the endpoint rather than on the attachment so that the file naming the command and the variable the command reads cannot exist one without the other. The endpoint stays out of the file. tunnel reads it for itself, so nothing records which gateway a workload had, and the port that belongs to the gateway image stays where it was. A target is checked before anything is dialled. It arrives from an ssh command line, which is a workload's to choose, and goes into a request head, so a host carrying a line ending could otherwise write a second request of its own to the proxy. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Paulo Gomes <paulo@entire.io> Claude-Session: https://claude.ai/code/session_01FyyRXjuCLgApR4jtCCSuHK Entire-Checkpoint: 01M2A4NT5GN9TVFXJCJSENGAF0
Five things about the session log, found in review of the commits that added it. A tail sized itself to what was asked for. -n is a number typed at a terminal and the ring was allocated to hold that many before a line had been read, so a large enough one panicked qubesome in makeslice rather than printing a log. It now grows as lines arrive, and evicts by moving an index rather than by shifting every line it holds, which is what made -n over a long log O(total * n). A line still being written was printed as though it were whole. The scanner returns an unterminated final token as a line, so a record the gateway was in the middle of writing was printed halved and its remainder read as a line of its own, splitting one decision into two. The tail is now held back and handed to the follow that continues from it. A follow outliving its gateway saw nothing of the next one. A gateway truncates the log it inherits, so the descriptor stayed past the new file's end and stepped over everything written until it grew back to where the old one had reached. A log that has shrunk is now read again from the start. Reading a line allocated before it checked. ReadString took the whole unterminated suffix and only then compared it against maxLineLen, so the limit bounded what was kept and not what was read. Reads are bounded by the buffer now, and an over-long line is discarded as it goes past. Both writers of the log shared a file and not an offset. The sandbox's descriptor was opened without O_APPEND, so it wrote wherever its own offset had reached, which is behind whatever the uplink had appended since and on top of it. Both append now. The error for a missing log also no longer asserts that no gateway was started. A gateway from an older qubesome runs without writing this file. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Paulo Gomes <paulo@entire.io> Claude-Session: https://claude.ai/code/session_01FyyRXjuCLgApR4jtCCSuHK Entire-Checkpoint: 01M2A4PJRBGJ3GMBZZHBEJ081X
A quoted value was read by dropping each backslash and keeping the byte after it, which is right for a quote and wrong for everything else. slog writes a message holding a newline as a backslash and an n, so what came back for msg="dial failed\nretry" was dial failednretry, and doctor reported an error the gateway had not logged. The escapes are Go's own, so strconv.Unquote undoes them. The scan still walks the token first, because finding where a quoted value ends is the part that has to tolerate a line slog did not write: one this cannot unquote falls back to what lies between the quotes, and one with no closing quote yields nothing, which is what the rest of this file does with anything it cannot make sense of. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Paulo Gomes <paulo@entire.io> Claude-Session: https://claude.ai/code/session_01FyyRXjuCLgApR4jtCCSuHK Entire-Checkpoint: 01M2A4Q0P4SVWFZ746SDB1F424
The root Before hook runs ahead of every command, and several of them have a caller reading their standard output as data rather than as text for a person. tunnel puts an ssh connection through it, completion is eval'd by a shell, and clipboard writes back what was pasted. A warning printed there is read as the far end talking, as shell input, or as part of the paste. It goes to stderr for the same reason a returned error already does, and it is still seen: stderr is where ssh shows what a ProxyCommand says. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Paulo Gomes <paulo@entire.io> Entire-Checkpoint: 01M2AE1DBVS2GETHN1XSZZT3D2
The status inferred it from whichever profiles were active, which answered wrongly exactly when it mattered. A gateway is session wide and outlives the profile that launched it, so once that profile has stopped there is nothing among the active ones to point at, and the nearest config was reported as the gateway's own: an image, a policy and a subnet the running gateway need have nothing to do with. It is now written when a launch actually creates a gateway, beside the state, lock, alloc and creds records that share its lifetime. A launch that finds one running reuses it whatever config it came from and records nothing, because the config a gateway came from and the last config anything opened are different things. The session directory and not the home directory, for the same reason the inference had to go: a record that outlives the session outlives the gateway it describes, and after a reboot it would name a config from a session that no longer exists. With no record and no gateway running, the user-level config is still the answer. There is nothing whose provenance could be got wrong, and what a status then describes is the gateway this host would start. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Paulo Gomes <paulo@entire.io> Entire-Checkpoint: 01M2AETN9F4RNFNQP2TRPJ7CJH
Running things in a profile
host-runno longer carries the launching session'sDESKTOP_STARTUP_IDandXDG_ACTIVATION_TOKENinto the profile. They were placing windows on theprofile's default workspace instead of the one being looked at.
host-runreturns once the command starts, and is authorised against theprofile's display.
DISPLAYby name ratherthan inheriting the host's, which the profile container holds so the
compositor can present through it.
localectlon Wayland, instead of always preferring the configured one. Aprofile was coming up on a layout its user does not type on.
TZrather than a mount.Seeing what the gateway is doing
start it, read back with
qubesome gateway logs, narrowed with-profileand
-workload.qubesome doctorreports the gateway's egress: refused hosts are named atok, since a denial is the policy working, and gateway errors warn.gateway statusno longer reports a config the running gateway may not havecome from.