Skip to content

feat(s16): desktop browser proxy lease + sensitive-host filter (#410 slice 2a) - #453

Merged
chayapats merged 2 commits into
mainfrom
jit/410-s16-desktop-lease
Nov 11, 2026
Merged

chayapats merged 2 commits into
mainfrom
jit/410-s16-desktop-lease

Conversation

@chayapats

Copy link
Copy Markdown
Owner

Part of #410 (DESK16-01), slice 2a. Implements ADR 0091 decisions 2, 3 and 5: the desktop browser's standing proxy lease and its sensitive-host filter. The guest desktop session and the pinned Chromium (decisions 1 and 6) come next in slice 2b. Champ picked pinned Chromium on 2026-11-11; this PR records that in ADR 0091.

What changed

  • Proxy (src/s15/vm/proxy.ts)
    • lease() takes optional per-lease rules. deny(host) refuses a host on the requested name before DNS, for every host on that lease, registry hosts included. Refusals are logged as denied. A filter that throws refuses.
    • Leases can be renewed with renew(expiresAt). A closed lease stays closed.
    • Work-session leases are unchanged.
  • Local VM control (local-control.ts, composition.ts)
    • With desktop on, the control exposes host.desktopLease, which always carries desktopDeny.
    • Composition passes websiteSensitiveOnly("https://" + host) as desktopDeny.
    • desktop without a filter is refused (vm_invalid_setup), so a desktop lease can't exist unfiltered.
  • QEMU manager (qemu.ts)
    • After every boot it issues desktop_<vm>_<random> and writes it over SSH to /etc/jit-desktop/proxy.env (0640 root:jit-desktop, atomic replace).
    • The lease is only written if the guest has the jit-desktop group. Otherwise, or if delivery fails, the lease is revoked right away and the start still succeeds.
    • A held lease is renewed every 10 minutes for 30 minutes. If the VM is no longer running at a renewal, the lease is revoked.
    • It is revoked before every protected stop (owner stop, idle stop, restore).
    • A per-revoke epoch means a delivery still in flight when the VM stops never installs its lease.
  • Controller restart (types.ts, service.ts)
    • New optional VmManagement.reattached(). After the forced health check passes for a VM that survived the restart, the service calls it.
    • The QEMU manager then issues a fresh desktop lease, because the restarted proxy holds none. If this fails, the VM is not fenced.
  • Docs
    • Contract s15-working-vm-v1: new paragraph "Desktop browser lease".
    • ADR 0091 decision 6 now records Champ's choice.

Not in this PR (slice 2b)

  • The jit-desktop user and group, Xorg/fluxbox, pinned Chromium, and jit-desktop.service.
  • Until 2b lands, no guest has the group, so no desktop lease is ever held in practice.
  • Note for 2b: Chromium does not accept credentials inside --proxy-server. The desktop unit will need a small loopback forwarder, running as jit-desktop, that adds the Proxy-Authorization header from proxy.env.

How to verify

  • bun test tests/s16-desktop-lease.test.ts passes 10/10. It covers:
    • sensitive hosts refused before DNS and logged denied, while a public host is admitted
    • a work-session lease on the same proxy is unchanged
    • the filter also applies to registry hosts; private answers are refused; a throwing filter refuses
    • renew and close behaviour
    • local control refuses desktop without a filter, and its desktop lease refuses accounts.google.com over the real Unix socket
    • lifecycle: issue on boot, renew, revoke on stop and on restore, a fresh lease on start, revoke when the VM is gone, no lease without the group, none for headless
    • a restarted controller's reattach() issues exactly one fresh lease
    • the guest writer refuses lease URLs that could break the env file
  • Related suites (proxy, qemu, local, screen, vmm) pass 109/0. In s15-vm.test.ts one test fails locally only because rg isn't installed on my machine.
  • tsc --noEmit is clean.

@greptile-apps

greptile-apps Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; both previous blocking findings are fixed, and no new actionable issues were found.

Summary

Adds a separate desktop browser proxy lease with a sensitive-host filter, renewal, and revocation around VM stops and controller restarts.

  • Desktop browser leases block sensitive hosts before DNS lookup.
  • Desktop VMs receive a renewable proxy lease while they run.
  • ADR 0091 records Champ's pinned Chromium choice.

Reviews (2) · Last reviewed commit: "fix(s16): desktop lease renewal refreshe..." · Reviewed by Greptile

Comment thread src/s15/vm/qemu.ts Resolved
Comment thread src/s15/vm/qemu.ts Resolved
…d lease issue never fails VM start (Greptile #453)
@chayapats

Copy link
Copy Markdown
Owner Author

@greptileai please re-review head 4fa48e5 (fixes for both P1s).

@chayapats chayapats left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duo review — head 4fa48e59dc94edcad6844a5efcd12f0eb9f79fb5

Verdict: ⚠️ 2 SHOULD, no blocker. Not a security hole in the host filter. Do not merge as the lifecycle a standing desktop lease will actually run on, unless Tria explicitly defers both recovery gaps to slice 2b and records that residual. Greptile's two P1s are fixed. CI on this head is green (typecheck, deterministic, x64/arm64 tests). Greptile approved this sha; that is not this review.

Verified fixed

  • Browsing stops after 512 MiB. renew() in src/s15/vm/proxy.ts:189 sets session.bytes = 0 as well as expiresAt. Only the desktop timer calls renew(), so a work-session lease still has one allowance for its life. The new test fails without the reset.
  • Full proxy blocks VM startup. issue() in src/s15/vm/qemu.ts:369 is inside the try. A throw leaves the VM running and not uncertain. Confirmed in the code and the new test.

SHOULD 1 — one failed renewal kills browsing until the next boot

src/s15/vm/qemu.ts:376-382

The 10-minute beat treats any inspect() throw as "not running" and closeDesktopLease() cancels the timer and closes the proxy lease. Nothing re-issues until the next launch() or controller reattached().

inspect() always takes the same QMP socket (qemu.ts:241, 30s timeout) that snapshot holds for up to 300s (qemu.ts:469) and snapshot-delete for up to 120s. QEMU's chardev drops the accept callback while a client is connected (char-socket.c tcp_chr_new_client), so the renewal's QMP waits for a greeting that does not come. If that wait exceeds 30s, inspect() throws, the standing lease is revoked, and the VM keeps running with dead credentials in /etc/jit-desktop/proxy.env. Any other inspect() throw (image probe, unit show) does the same.

A missed beat is already safe: the lease is issued for 30 minutes and renewed every 10. Revoking on "unproven" is what makes a busy socket permanent.

Fix: on throw, skip the beat. Revoke only when inspect() returns a non-running state. Keep the timer so the next beat can renew.

SHOULD 2 — a failed issue or delivery is never retried

src/s15/vm/qemu.ts:369 and the early return at 372-374

Asked explicitly: a single attempt with no retry is not acceptable for a lease that is supposed to last as long as the VM runs. issue() throwing (full 64-lease pool), SSH failing, or the guest writer not printing jit-desktop-lease-v1 all return without arming the timer. The VM stays up. The browser has no network until the next boot or a controller reattach. That is the right fail-closed outcome for the attempt, and the wrong steady state: a transient SSH blip at the one window becomes an outage of hours.

This slice never holds a lease in practice (no jit-desktop group yet), so it does not bite until 2b. The timer this PR adds is the place to retry. Tria may defer both SHOULDs to 2b, but they should be an explicit residual, not dropped. 2b must also create the group before launch() returns, or call this path again after cloud-init: openDesktopLease() runs at the end of launch(), which is before provision() waits for cloud-init.

Fix: arm the same beat when no lease is held, and call openDesktopLease() again while inspect() shows running.

NIT

  • Contract vs the byte reset. The new "Desktop browser lease" paragraph in docs/contracts/s15-working-vm-v1.md says the lease is renewed every 10 minutes for 30, and that it follows work-session rules. After 4fa48e5 the standing lease gets a fresh 512 MiB per renewal period, not one 512 MiB for the VM's life. One clause so the spec matches the fix.
  • Env-file check misses $. src/s15/vm/qemu.ts:623 rejects newline, CR, NUL, space, quotes and backslash. systemd EnvironmentFile= expands $. Today's URL cannot contain $ (vmId session id, hex password, fixed 192.168.5.100). Add $ to the reject set before a hostname change can inject. I ran the evaluated writer: bad URLs are refused; a successful write is mode 0640 and rename replaces a symlink instead of following it. CI never runs that success path (no jit-desktop group; FakeQemuHost matches the substring and does not execute the script).

Open question, not a defect

The filter is websiteSensitiveOnly("https://" + host) before DNS, fail-closed if it throws, and it does not apply to work-session leases. I could not bypass a listed name: CONNECT hosts are [a-zA-Z0-9.-], trailing-dot names fail vmPublicWebHost, and the same host is used for the check and for DNS. ADR 0091 already records the list as a heuristic. Concrete names that stay reachable, if Champ wants them blocked before 2b (one-line list change, no ADR): gmail.com, www.gmail.com, outlook.com, outlook.office365.com, idmsa.apple.com (Apple's actual sign-in host; appleid.apple.com is blocked), oauth2.googleapis.com.

Checked and dropped

Stop, idle stop and restore all go through halt() → closeDesktopLease(). A surviving VM gets one fresh lease on reattach. The desktop session id passes vmId (54 chars, [A-Za-z0-9_-]). An in-flight delivery is not in #desktopLease, so a direct halt() would not close it until the SSH returns; the service #managementBusy lock makes that overlap unreachable. Not a finding.

@chayapats

Copy link
Copy Markdown
Owner Author

⚖️ Tria ruling on Duo's review @ 4fa48e5: ✅ Pass for 2a. Both SHOULDs are real defects, and both move to 2b as a required residual. They are not dropped.

Reasons

  • Both Greptile P1 fixes hold: proxy.ts:189 renew() resets session.bytes, and qemu.ts:369 catches the issue() throw. CI is 15/15 green on 4fa48e5, Greptile passes, and both P1 threads are resolved.
  • SHOULD 1 is real. inspect() calls QMP with a 30 s timeout (qemu.ts:277, 241), while snapshot() can hold the QMP socket for up to 300 s (qemu.ts:469). renew treats any throw as not-running and calls closeDesktopLease() (qemu.ts:379-381).
  • Neither defect can bite in 2a. Nothing in src/ creates the jit-desktop group, so the writer prints jit-desktop-absent-v1 (qemu.ts:624-625). That means delivered=false, the lease is closed at once (qemu.ts:376), and the renewal timer is never armed. No standing lease exists yet that could die or need a retry.
  • Both defects live in the same beat loop, and 2b has to rework lease timing anyway: openDesktopLease() runs at the end of launch(), before provision() waits for cloud-init. One combined design in 2b beats patching SHOULD 1 now and rewriting it later.
  • One change to Duo's proposed fix: a vm_boundary_unproven throw (qemu.ts:269/278) must revoke, not skip the beat. That keeps the fail-closed posture used across s15. Only transient errors (QMP busy or timeout, management unavailable) skip the beat.

Next

  1. Mono records the residual as a follow-up issue, or as acceptance criteria on the 2b slice under [Frontier] [DESK16-01] Watch and take over a working computer safely #410. Spec: one 10-min beat that does the following:
    • running and a lease is held: renew.
    • running and no lease is held: call openDesktopLease() again (covers a full pool and a failed delivery).
    • transient inspect() throw: skip the beat and keep the timer.
    • proven non-running, or vm_boundary_unproven: revoke.
    • also: the jit-desktop group must exist before launch() returns, or the lease must be re-issued after cloud-init.
    • required tests: (a) a snapshot holding QMP does not kill the lease; (b) with the pool full at boot, a later beat obtains a lease.
  2. NITs (the contract paragraph should say renew resets the 512 MiB allowance each period; add $ to the env-file character check at qemu.ts:623) go in the same issue, or into this PR if you prefer. If you push, wait for CI to be green on the new head.
  3. Mono replies to Duo's review with the issue link. Then Mono may merge under Champ's rule: every CI check green on the latest head, no unaddressed Greptile comments, and every review comment addressed.

Open policy question for Champ, not a blocker: whether to add gmail.com, outlook.com, outlook.office365.com, idmsa.apple.com and oauth2.googleapis.com to the sensitive filter before 2b.

Confidence: high (verified against the code at 4fa48e5).

@chayapats

Copy link
Copy Markdown
Owner Author

Reply to Duo's review on 4fa48e5 (Tria ruled Go for 2a: #453 (comment)).

Both SHOULDs (inspect() throw should skip the beat instead of revoking, and retry openDesktopLease() when no lease is held) and both NITs (contract text about the 512 MiB reset per period, and $ in the env-file check at qemu.ts:623) are tracked in #454 as a REQUIRED residual for #410 slice 2b. #454 includes the acceptance criteria and the two tests Tria specified. Slice 2b will not merge until #454 is done.

The open question about extra sensitive hosts (gmail.com, outlook.com, outlook.office365.com, idmsa.apple.com, oauth2.googleapis.com) is recorded in #454 for Champ to decide.

Nothing in this PR's code changes, so the head stays 4fa48e5.

@chayapats
chayapats merged commit 51377d1 into main Nov 11, 2026
15 checks passed
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.

1 participant