Commit Graph
100 Commits
Author SHA1 Message Date
Wanjohi d288e6ad56 fix(install): check this machine before stopping the host agent, keep the box store on upgrade
On a host already running the agent, run preflight first and stop the agent only if it passes, so a failing check no longer leaves the machine offline. An existing host keeps the box store from its environment file instead of being asked, since the guessed default moved a store and hid its games. --gpus is accepted after the token and reaches onboarding.
2026-10-18 16:41:21 +03:00
Wanjohi 3f578252d0 chore(install): serve host agent 0.2.22 (#443)
Points the installer at the 0.2.22 release.
2026-10-18 13:18:52 +00:00
Wanjohi 9aed4e1156 chore(install): serve host agent 0.2.22 2026-10-18 16:17:49 +03:00
Wanjohi 45d19b048a fix(nescope): default log filter to info when RUST_LOG is unset (#441)
nescope used `EnvFilter::from_default_env()`, whose default is ERROR, so
its info/warn lines never reached the host journal. nesinit only
forwards RUST_LOG if it has it itself.

- nescope: fall back to `info` when RUST_LOG is unset or unparsable
(same as neshub, nesinit, nesgamepad); debug stays opt-in. 4 unit tests
on the filter helper.
- neswire: same ERROR default, same fix.
- Not changed: nescapture (library inside the game process, env_logger).

Tested on nestripc-1: `cargo test -p nescope log_filter` 4 passed,
`cargo check -p neswire` ok.
Note: no 'configure_request clamped' warn exists on this base; once it
lands at warn level it is covered by the info default.
2026-10-18 12:53:26 +00:00
Wanjohi af3f8f3439 fix(neswire): log dropped audio frames once per burst, not once per frame
A stalled encoder drops about fifty frames a second, and with warn now
visible by default each drop would be its own journal line. Count them
and log the first and every 250th.
2026-10-18 15:53:08 +03:00
Wanjohi a43cb9fc75 fix(nescope): default log filter to info when RUST_LOG is unset
EnvFilter::from_default_env defaults to ERROR, so nescope's info and warn
lines never reached the host journal. Fall back to info like neshub,
nesinit and nesgamepad; align neswire, which had the same default.
2026-10-18 15:50:08 +03:00
Wanjohi 3e180c3b41 fix(nescope): a hub that closes the input socket is not a panic (#440)
Found in a passing multi-launch probe on NestriTest
(nesprobe-1792327018): at shutdown nescope logged `panicked at
apps/nescope/src/main.rs:639:10: Event loop error: ... IPC socket
closed`.

Cause: `InputIpcSource::process_events` returned EOF as `Err`; calloop
passes that out of `run()` into the `.expect("Event loop error")`. At
box shutdown nesinit does `kill(-1, SIGTERM)`, so neshub can exit before
nescope reads its own SIGTERM, and the panic skips the cleanup after the
loop (socket removal, child kill, the clean-exit log).

Change: on EOF, deliver the frames already read, warn, and return
`PostAction::Remove` so the loop carries on and the signal or auto-exit
path ends it. Read loop split into `fill()` so it is unit-tested with a
socket pair.

Tested on nestripc-1 (CARGO_TARGET_DIR=~/target-fix-nescope-ipc-exit):
`cargo test -p nescope`, 3 new tests pass.
2026-10-18 12:47:06 +00:00
Wanjohi 1bbf15fdca fix(nescope): end nescope when the hub stays gone after its socket closes
The input source now removes itself on EOF or reset and raises a flag; the
main loop exits 2 s later unless a signal already ended it, so a hub crash
mid-run still ends the compositor (and the box via CompositorDied) instead of
holding a GPU with no input. Also corrects the comment that promised a
reconnect that does not exist.
2026-10-18 15:46:14 +03:00
Wanjohi 6c2ae15941 fix(nescope): a hub that closes the input socket is not a panic
The input source returned UnixStream EOF as an error, which calloop passes
out of run() into the expect at the end of main. At box shutdown init
signals every process at once, so neshub can go before nescope reads its
SIGTERM: nescope panicked and skipped its cleanup. Deliver what was read,
remove the source and warn instead.
2026-10-18 15:43:19 +03:00
Wanjohi eb0a276774 fix(nescope): a window cannot ask for more than the output has (#438)
Phase 0 of decision 0086.

Cause (inferred from the code, not observed live): the client's window
size resizes the box's output, but a Proton game under XWayland
re-asserts its launch size with a ConfigureRequest, which
configure_request honoured. The window ends up larger than the output;
the pointer lives on the output (clamp_cursor), so the cut-off part of
the window is unreachable. The client sends relative motion only, so no
client-side mapping is involved.

Fix: configure_request clamps a non-override-redirect X11 window's
requested size to the current output size (and keeps it inside the
output). The output keeps following the client. Pure fn clamp_to_output,
unit-tested incl. width-only, height-only and multiple windows.

Risk: a game that ignores the configure and keeps drawing at its own
size will still be cropped. Wayland toplevels are unchanged (they
already get the output size).

Live proof is still pending: pointer reach on a real Proton window after
a resize has not been exercised.
2026-10-18 12:20:05 +00:00
Wanjohi 4d44e7e59d feat(nescope): log clamped configure requests and warn on a ping-pong 2026-10-18 15:19:23 +03:00
Wanjohi 7059c4812c fix(nescope): a window cannot ask for more than the output has 2026-10-18 15:18:10 +03:00
Wanjohi 2ff74fbe01 chore(install): host agent 0.2.21, desktop app 0.1.3 2026-10-18 09:20:46 +03:00
Wanjohi 267f36d5bc feat(nesinit): the box starts one daemon for its whole life, beside the compositor (#434)
Adds an optional `daemon` to the boot descriptor (decision 0085).
nesinit starts it after the compositor, as the given uid, announces it
in /run/nestri/daemon before the box is ready, lists it among the box's
services, and on its exit reports ServiceDied by name and ends the box
(as for the compositor). Optional both ways: an old descriptor reads the
same; an old guest refuses a new descriptor and the host already reports
that as a stale image. Tests: protocol round trip, started after the
compositor, start failure refuses the box, exit reported.
2026-10-18 05:31:05 +00:00
Wanjohi 274c6bface feat(api): long-poll GET /machine/jobs (#435)
Cuts host job pickup from the heartbeat interval to under a second.

`GET /machine/jobs?wait=<s>` (capped at 25) answers at once when there
is work, else holds until woken or the wait is up, then answers empty.
Without `wait` nothing changes.

Wake (in-process, keyed by machine id, fired after commit through
`Database.effect`):
- run requested: `Session.create`
- stop asked for on a starting or live run: `stopRun`
- launch added: `addLaunch`; launch stop asked: `stopLaunch`
- run ended or failed (frees a slot for a queued run):
`compareAndSetState`

Not woken, covered by the 5 s recheck: trial and weekend-window expiry,
a heartbeat that raises slots.

Multi-process: `redis` is in `apps/api/package.json` but nothing imports
it and nothing configures a URL, so no pub/sub path here. Each held call
re-reads every 5 s, so a wake that lands in another process costs a few
seconds. Add a channel when a second process exists.

Worker runtime: answers at once (no hold). Bun: `idleTimeout: 60` added,
since the default 10 s would cut the held call.

Tests: `apps/api/test/jobs-wait.test.ts` (8, on the local test Postgres)
plus the full API suite, 216 pass. Pairs with the neslet PR in nescore
(`feat/jobs-long-poll`); an old neslet is unaffected and a new neslet
against an old API falls back to its interval.
2026-10-18 05:10:02 +00:00
Wanjohi 4fe1b5b343 feat(api): hold GET /machine/jobs while every job is one the host has seen (?seen=) 2026-10-18 08:06:39 +03:00
Wanjohi d5c5a43d0f feat(api): hold GET /machine/jobs open with ?wait= and wake it when work appears
A host found new work on its next poll, 8 to 21 seconds on average. With wait (at most 25 s) a call that finds nothing is held until a run is requested, a stop is asked for, a launch is added or stopped, or a run ends and frees a slot, then answers. A held call also looks again every 5 s so a wake in another process costs seconds, not the job. Workers answer at once. Bun's idle timeout is raised so the held call is not cut.
2026-10-18 07:58:03 +03:00
Wanjohi 6a9f08edb1 fix(nesinit): the test double carries the daemon fields 2026-10-18 07:56:43 +03:00
Wanjohi fc4aeb086a feat(nesinit): the box starts one daemon for its whole life, beside the compositor 2026-10-18 07:55:38 +03:00
Wanjohi dbc53b469c fix(nesinit): a launch is a session of its own and the box owns the compositor (#433)
Stacked on #432 (fix/nesinit-loop-held); its commits show here until it
merges.

Stopping a launch in a two-launch box left that launch's game running:
`stop_launch` signalled only the launch's top process, so the children
(compositor wrapper, wine, the game) survived and the window never
closed. Killing the whole group naively would have taken the box's one
compositor, which the other launch is drawing on.

- Each launch runs under `setsid`. A stop signals the whole session
(SIGTERM, SIGKILL after 5 s). Members are found from /proc by session
id, so children that moved to a group of their own are still reached.
- A launch's exit is delivered only when its session is empty. If the
top process leaves by itself with leftovers, they are ended first.
- The compositor is a box-level process: the boot descriptor carries an
optional `Compositor` (geometry, rate, uid, env) and nesinit starts it
before the box reports ready, waiting for its socket. Absent, nothing
changes. A failure to start it refuses the box like a failed service.
- SIGKILL on the way down also takes each launch's group.

Tests: real-process tests for stop reaching children, a child ignoring
SIGTERM being killed after the grace, a top process leaving with
leftovers, and one launch's stop leaving another's group alone; session
tests for the compositor start and refusal; descriptor round trip. All
of nesinit and nesprotocol pass on nestripc-1.

Host side: nestrilabs/nescore (fix/launch-group-nescore). A host that
sends `compositor` needs this guest image first, because the descriptor
refuses unknown fields.
2026-10-18 04:46:32 +00:00
Wanjohi ceebee7183 fix(nesinit): a launch's group survives a failed join, a re-add and a busy rmdir; a dead compositor ends the box 2026-10-18 07:28:56 +03:00
Wanjohi ecbcec023f fix(nesinit): a game added to a running box mounts, and a box takes new games after one closes (#432)
Two bugs found running 0085 on NestriTest (2026-10-17):

- **Loop devices released before mount.** `loop_attach` configured
`/dev/loopN` with `LO_FLAGS_AUTOCLEAR` and dropped its fd before
mounting, so the kernel detached it and the writable layer's attach got
the same device. The build image mount then read ext4: `erofs (device
loop0): cannot find valid erofs superblock` → EINVAL. The device fd is
now held until the mount is made.
- **A box refused new launches after one ended.** A terminal launch
exiting beside another marked the box ending and refused every new
launch, so re-opening a closed game failed with "the box is ending". Per
0085 closing one window is not the person done: the box still takes
launches and ends when its last one goes.

Verified live on NestriTest: Everhood mounted (loop0 erofs, loop1 ext4)
beside Dome Keeper. nesinit tests: 124 pass.
2026-10-18 04:27:55 +00:00
Wanjohi d5dbefe9d0 fix(nesinit): a launch owns a cgroup, so a stop reaches Wine's own sessions
Wine setsid's its processes, so stopping a launch by session left wineserver,
explorer.exe and the game running with their window open. Each launch is now
put in cgroup /sys/fs/cgroup/nestri-launch-<id> before exec; stop is SIGTERM to
its members, then cgroup.kill after the grace, and the launch ends when the
cgroup is empty. Without cgroup v2 the members are the session plus every
process whose environment carries NESTRI_LAUNCH_ID. cgroup2 is mounted at
/sys/fs/cgroup early in boot.
2026-10-18 07:17:06 +03:00
Wanjohi 4d97424cc9 fix(nesinit): a launch is a session of its own and the box owns the compositor
Stopping a launch signalled only its top process, so the compositor wrapper,
wine and the game survived and the window stayed open. Each launch now runs
under setsid; a stop signals the whole session (TERM, then KILL after 5s) and
a launch counts as ended only once its session is empty.

The compositor moves out of the first launch into the box: the boot
descriptor carries an optional Compositor (geometry, rate, uid, env) and
nesinit starts it before the box reports ready, so every launch joins it.
2026-10-18 06:44:03 +03:00
Wanjohi aaa8bfa994 fix(nesinit): a box takes a new launch after one of its games has closed 2026-10-17 21:34:35 +03:00
Wanjohi be718bb756 fix(nesinit): hold a loop device open until its mount is made, or autoclear hands it to the next attach 2026-10-17 21:16:55 +03:00
Wanjohi 482ca620a9 fix(build): the loop count line carries no trailing comment, or the kernel rejects it 2026-10-17 20:24:47 +03:00
Wanjohi 3ce8aee427 fix(api): the same game added to a run twice is one launch (#426)
Follow-up to #422, from the nesktop #3 review. POST /session/{id}/launch
for a game that already has an open launch (requested or running, no
stop requested) in the run now answers that launch with 200 instead of
inserting a second one. The check runs under the run's row lock that
addLaunch already takes, so two concurrent adds resolve to one launch.
OpenAPI documents the 200.

Tested: apps/api 208 pass against the local test Postgres, including a
new test adding the same game twice concurrently.
2026-10-17 16:51:25 +03:00
Wanjohi 0c348d3973 fix(neshub): a picture switch waits for the new keyframe even behind a stalled writer (#425)
Follow-up to #423 from its re-review. The dgram writer now raises
awaiting_keyframe when it reaches the Resubscribe marker, in queue
order, and restart_picture sets the flag before queuing the marker.
Before this, an old keyframe still queued ahead of a primary switch
could clear the flag, letting the new picture's first delta out as the
first frame an old client saw after the switch. Needs a writer stalled
for at least 1 s to trigger.

Tested: cargo test -p neshub on nestripc-1, 78 passed.
2026-10-17 16:47:36 +03:00
Wanjohi 4a0a790fd1 feat(nesinit): several launches in one box, and mounting into a running box (#424)
Runtime half of the window streams contract (joins 1 and 6).

- nesinit runs any number of launches side by side, keyed by launch id.
Each process gets NESTRI_LAUNCH_ID
(nesprotocol::lifecycle::LAUNCH_ID_ENV), set after the caller's
environment so it cannot be overridden. Stop and restart reach one
launch only. A second launch under an id that is already running is
refused.
- A terminal launch ends the box only when no other launch is running,
so the box stops when its last launch exits. A box with one launch
behaves as before.
- New lifecycle messages, additive, no CONTROL_VERSION bump: host to
guest `mount {id, images, overlays}`; guest to host `launch_mounted
{id}` / `launch_mount_failed {id, reason}`. A failed mount does not take
the box down.
- A mount source that is a regular file (in a virtiofs share) is
attached to a loop device first (LOOP_CONFIGURE, autoclear), because
nesbox cannot hot-add block devices to a running VM. The guest kernel
fragment gains CONFIG_BLK_DEV_LOOP=y, so this needs a new guest
kernel/image to work end to end.

Tests: cargo test -p nesinit (117 passed) and the nesprotocol lifecycle
tests on nestripc-1. New session tests cover two launches side by side,
refusing a duplicate id, stopping one launch, the box ending with its
last launch, and mount success/failure/before boot. Not run in a real
box.

Companion: nestrilabs/nescore feat/window-streams-runtime (neslet
launch.start/launch.stop, nesteam --join-compositor).

Content layout and trust:
- Game and compat images come from a read-only virtiofs share
`launch-images` (mounted read-only at
/nestri/launch-images/<launchId>/build.erofs and compat.erofs). The
per-launch writable layer is in the read-write share `launches`
(/nestri/launches/<launchId>/upper.ext4). Which shares exist is decided
host-side in the boot descriptor; nesinit mounts what it is told.
- The share's only guest-writable files are the per-launch upper.ext4
files.
- Mount sources are opened with openat2(RESOLVE_NO_SYMLINKS): a link at
any component is refused, and only block devices and regular files are
accepted. Loop attach and mounts run on the blocking pool, so other
launches' exits and stops are not stalled.
- CONFIG_BLK_DEV_LOOP must ship in the guest kernel together with this
nesinit (build/kernel/nestri.fragment has it); without it a mount into a
running box fails with a launch_mount_failed.
2026-10-17 16:37:05 +03:00
Wanjohi f889516ef4 feat: window streams in the box (nescope, nescapture, neshub) (#423)
The box half of phase 1 of window streams: two games in one box, each
its own stream. Wire formats are nesprotocol's `window` module (#421).

## What each app does now

**nescope**
- Gives every toplevel an id on first map: 0 for the first window, never
reused, none past 65535.
- Tells the hub, on the existing input socket, when a window opens,
changes title or size, and closes, in nesprotocol's window encoding. It
also sends a box-internal `MSG_WINDOW_PID` saying which process owns the
window: the Wayland client, or `_NET_WM_PID` for Xwayland. The launch id
comes from that process's `NESTRI_LAUNCH_ID`.
- Handles `INPUT_WINDOW_FOCUS` by raising and focusing that window, and
`INPUT_WINDOW_OUTPUT_SIZE` per window. The old `INPUT_OUTPUT_SIZE` means
window 0. There is still one output: a window's size applies while that
window has focus and is remembered otherwise. On every input connection,
input resets to window 0.
- Before cursor updates, sends `MSG_CURSOR_WINDOW` when the window they
describe changes.

**nescapture**
- Tags each video IPC frame with its pid in a trailer (`IPC_FLAG_PID` in
the reserved byte). An older reader never sees it.
- Listens on `/tmp/nescapture-cmd-<pid>.sock`. It still binds the shared
`/tmp/nescapture-cmd.sock` and relays from it, so whatever sends there
still reaches the newest encoder, as before. The shared socket was the
only resource two capturing processes fought over: the second one took
it, and commands went only to that one. The video and stats sockets are
send-only datagram sockets and are safe to share. Capture slots and
devices are per process.

**neshub**
- Joins frames to windows by pid. An untagged frame is window 0. A pid
with no window falls back to the newest window of its launch, then to
window 0 while no other window is open. A pid whose window closed
belongs to nobody.
- A connection that never subscribes gets the box's primary picture in
the old encoding. The primary picture is the frames of the first encoder
to draw, until it has been silent for a second. A subscribed connection
gets only its window's frames, windowed for any window but 0, with
per-window seq. PyroWave stays window 0 only.
- `MSG_WINDOW_SUBSCRIBE` switches the connection first, then resets the
writer's seq and resync gate in line with the frames, then requests a
keyframe. The first subscribe opens `STREAM_WINDOWS` on the video
connection, starting with `OPENED` (and the icon, if known) for every
open window. Later lifecycle events go to every subscribed connection.
- Input to nescope carries `INPUT_WINDOW_FOCUS` only when the window
changes, and the hub does no merging across tags. A gamepad message
moves focus only when its window differs from the current one and no
other client sent keyboard or pointer input in the last second. Focus
tags move keyboard and pointer focus only: nescope neither raises the
window nor resizes the output for one. The output changes size only on
an explicit `INPUT_WINDOW_OUTPUT_SIZE` from the window that owns it,
last writer wins with 500 ms hysteresis between windows. A client's
`INPUT_OUTPUT_SIZE` is rewritten to `INPUT_WINDOW_OUTPUT_SIZE`, except
for window 0.
- A cursor update goes to the clients subscribed to the window it
describes. Clients that never subscribed get every update, as before.
- When nescope reconnects (a new compositor), the hub sends `CLOSED` for
every window and every subscription goes dead until the client
subscribes again.
- `DatagramSender` sizes fragments with `max_fragment_payload` and
writes headers with `write_video_datagram_header`.
- Commands go to every known encoder on its own socket, or to the shared
socket while none is known (as today). An encoder that appears later is
sent the commands so far (the latest of each message type) and then a
keyframe request. The first encoder is not sent them again.
- A keyframe on a client's loss or subscribe goes only to that client's
window's encoder (the primary's, for a client that never subscribed).
- Known limit: bitrate is box-wide. One controller sets one target for
every window's encoder, so one client's congestion sets every window's
rate. Per-window rate is follow-up work.
- An X11 unmap is `CLOSED` for the hub, and a re-map is `OPENED` with
the same id. nescope no longer exits when its first game exits; nesinit
owns box power-off. The hub never reads `/proc/<pid>/environ` (it runs
as another uid): nescapture sends its launch id.

## Shipping

nescope and neshub ship together in the guest image and must not ship
apart.

## Compatibility

One game with an old client sends the same bytes as before. The client
never subscribes, so it gets the primary picture, which is that game, as
`DGRAM_VIDEO`/`MSG_DATA` with an unchanged seq. It gets no windows
stream. Cursor updates reach it as before. Focus tags appear only if the
game's window is not 0, and window 0 resizes use the old form. Mixed
versions also work:
- An old nescapture is window 0.
- An old nescope never sends window messages. Every frame is then window
0, or the primary picture.
- An old hub sends no tags, which nescope reads as window 0, and it
still reaches nescapture through the shared socket.

## Tests

`cargo test` on nesprotocol (122), nescope (17+5), nescapture (161),
neshub (74), all passing. New tests:
- The window table: joining pids to windows, the launch fallback,
retired pids, the primary picture, the snapshot.
- Focus tagging and resize rewriting.
- The command fan-out.
- nescope's id registry (first window 0, ids never reused, refused past
65535) and per-window focus and sizes.
- The IPC pid trailer.
- Two QUIC end-to-end tests:
- Two tagged encoders: an unsubscribed client sees only the first game
in the old encoding. After subscribing to window 1 it sees only the
second game, windowed, with seq starting again, and a windows stream
that starts with both `OPENED` events.
  - An untagged encoder gives the old stream.

Not run on a box: no neslet, nestri-host or box was touched.
2026-10-17 16:36:44 +03:00
Wanjohi 7209e910d9 feat(api): launches under a session (#422)
Phase 1 of decision 0085, the api piece (see .nestri contract
window-streams).

A box run (session) now holds launches: one game or app each. Billing,
allowance and the free weekend still count sessions; nothing about
limits changes. session_box_active_unique stays.

- Migration 0027: launch table (id lch_..., session_id, game_id, state
requested|running|ended|failed, first, time_started, time_stopped,
time_stop_requested, end_reason, error_message). Backfills a first
launch for every run that has not stopped.
- POST /session: same request; the response data gains launches (the
first launch).
- POST /session/{id}/launch {gameId}: 201 with the launch. Same library
check as POST /session. 409 unless the run is starting or live and not
being stopped.
- POST /session/{id}/launch/{launchId}/stop: marks the launch; host gets
launch.stop until it reports it over. A launch in a run no host has
claimed ends at once.
- POST /session/{id}/launch/{launchId}/state (host, run's claimToken):
running | ended | failed, optional endReason/errorMessage.
- When the last launch is over, the run is stopped through the same path
as POST /session/{id}/stop. A run ending ends every launch in it.
- GET /session/{id} gains launches.
- GET /machine/jobs: session.start gains launchId (the first launch);
new kinds launch.start and launch.stop. Agents skip unknown kinds, so
old hosts behave as before.

Tests: apps/api 203 pass (new test/launch.test.ts), packages/core 256
pass, against the local test Postgres.
2026-10-17 13:16:50 +03:00
Wanjohi 5d49066189 chore(install): host agent 0.2.20 (#419)
Brings dev's install version in line with prod (#418).
2026-10-17 12:56:05 +03:00
Wanjohi fe2b8d6e35 feat(api): place fleet boxes by boxes per slot (#420)
Fleet placement picked the online machine with the fewest boxes,
whatever its capacity. A box never moves, so a two-slot host got as many
people as an eight-slot one and queued while the bigger one idled.

Now it picks the fewest boxes per slot (the `slots` each host reports in
its heartbeat). A host that reports none counts as one slot, so today's
fleet behaves as before.

- New test: a 2-slot and an 8-slot machine, five people, four land on
the bigger one.
- api 194, core 256 pass.
2026-10-17 12:55:49 +03:00
Wanjohi c4bb12db6a feat(nesprotocol): window ids on video, window lifecycle and subscribe messages (#421)
Protocol for several windows in one box, each its own stream,
ref(d-0085). Design in `crates/nesprotocol/src/window.rs` module docs.

- Video: window 0 is byte-identical to today (DGRAM_VIDEO / MSG_DATA).
Other windows use DGRAM_VIDEO_WINDOW (4, 17-byte header with u16 window
id after sent_us) and MSG_WINDOW_DATA (0x01, u16 window id after seq).
- Lifecycle (hub -> client, new uni STREAM_WINDOWS = 8, length-prefixed
frames): OPENED 0x20, TITLE 0x21, ICON 0x22 (RGBA), SIZE 0x23, CLOSED
0x24.
- MSG_WINDOW_SUBSCRIBE 0x17 on the control stream; never sent means
window 0. Old hubs log it as unknown and continue.
- Box-internal input: INPUT_WINDOW_OUTPUT_SIZE (5) and
INPUT_WINDOW_FOCUS (6) tag resize and input with a window; client
INPUT_OUTPUT_SIZE unchanged.
- Cursor unchanged: per subscription.

nescope gains a no-op match arm for the two new DecodedInput variants.

Tests: cargo test -p nesprotocol --all-features, 140 passed. cargo check
--workspace (excluding nespyro/nescapture, slangc missing on the build
box) clean.
2026-10-17 12:55:30 +03:00
Wanjohi b7ff13578e fix(nescope): a window larger than the launch size keeps its shape 2026-10-17 12:12:33 +03:00
Wanjohi 176dd2dc2b feat(nescope): the box's output follows the client's window size
A new input event, INPUT_OUTPUT_SIZE [4][w u16][h u16], rides the input
path to nescope, which resizes its output and every window on it. The
size is clamped to the launch size, so the box's encode budget holds,
and kept even for 4:2:0. apply_resize existed but nothing called it.

Old hubs stop a batch at an unknown type, so clients send it alone.
2026-10-17 12:10:10 +03:00
Wanjohi eee3727d0c feat: add a host by approving it in a browser, without pasting a token (#416)
The installer no longer needs a token copied from the dashboard. Run
bare, the host agent shows a link, a code and a QR; approving it in a
browser registers the machine.

**Issuer**
- New device client `installer`. Its access token lasts 15 minutes and
it gets no refresh token: a refresh lifetime of 0 now means none is
stored or returned.
- `clientTTL(clientID)` sets lifetimes per client and wins over what a
sign-in asks for. Desktop is unchanged.
- `/device/authorize` allows 10 requests per address per 10 minutes.
Past that it answers 429 `slow_down` with `Retry-After`.
- The confirm page for the installer reads "Add a computer as a Nestri
host?". The wording is fixed per client id.

**API**
- An `installer` token is accepted only on `POST
/machine/install-token`. Everything else answers 403
`insufficient_permissions`.
- Install tokens: at most 5 per person per hour, and at most 3 unspent
at once (429 `too_many_requests`).
- A team may own at most 10 machines. This is checked at mint and at
redeem under a row lock (429 `quota_exceeded`). A refused redeem leaves
the token unspent. Organisation hosts are not counted.

**install.sh**
- The token is optional. With none, `NESTRI_INSTALL_TOKEN` is left unset
and onboarding signs in.
- **Do not release this file until `DEFAULT_VERSION` points at a host
agent that can sign in.** Older agents need a token.

Tests: packages/core 256, apps/api 193, packages/auth 74, apps/auth 28,
all passing.
2026-10-16 20:49:02 +03:00
Wanjohi 2e9705c912 chore(install): host agent 0.2.19 (#415)
Brings dev's install version in line with prod (#414).
2026-10-16 20:47:48 +03:00
Wanjohi 6bbce800c7 feat(install): bash <(curl …) -u updates a host; the script asks sudo itself (#411)
```
bash <(curl -fsSL https://api.nestri.io/install.sh) -u            # update this host
bash <(curl -fsSL https://api.nestri.io/install.sh) <install-token> # make this machine a host
bash <(curl -fsSL https://api.nestri.io/install.sh) -h            # help
```
Piping still works: `curl -fsSL … | sh -s -- -u`.

**`-u` / `--update`.** No token and no questions. On a registered
machine it downloads and checksum-verifies the current host agent, swaps
it in and restarts the service. The agent then pulls whatever its
release changed (a raised image layout re-pulls the guest, jail and
Proton). It refuses:
- on a machine that was never installed, before downloading anything;
- while a box is running, since restarting the agent would end that
session;
- on an unknown option.

On a machine that already has the target version, it says so and exits
0.

**Root, asked once.** The host agent runs as root, so when the script is
started as anyone else it asks sudo once, before it changes anything.
Neither a pipe nor `bash <(…)` leaves a file sudo could run again, so it
fetches the same script to a private temp file, runs that as root with
the same mode and token, and removes the copy as soon as root has it
open. Only the settings somebody actually set (`NESTRI_HOST_VERSION`,
`NESTRI_BIN_DIR`, `NESTRI_BOX_STORE`) are carried across.

**Tested.**
- **On an NVIDIA host:** `--update` took it 0.2.17 → 0.2.18, verified
and restarted. A second run said "already 0.2.18. Nothing to do".
- **On the fleet host, run as an ordinary user** with `bash <(curl …)
-u`:
- it asked for sudo, the root copy ran and said "Nothing to do", and no
temp copy was left behind;
  - `-h` prints usage, and an unknown flag is refused.
- **In a clean container:** `-u` refuses with "not a Nestri host yet",
and a run with no token asks for one.
2026-10-16 00:31:51 +03:00
Wanjohi b50dce2cef feat(session): a finished session says why it ended (#412)
Until now every finished run said only `ended` or `failed`. A user stop,
a trial window running out, an idle timeout, a host restart and a game
quitting were indistinguishable afterwards.

**Schema.** Migration `0026_session_end_reason` adds two nullable
columns to `session`:
- `end_reason`: one of `user`, `window`, `hours`, `idle`, `left_queue`,
`game_exited`, `launch_failed`, `host_stopped`, `host_restart`.
- `time_claimed`: when a host took the run, set on `requested →
starting`.

**API.**
- `POST /session/:id/state` takes an optional `endReason`. With `ended`
or `failed` it is stored. With `starting` or `live` it is a 400 and the
run does not move.
- Leaving it out of a terminal report stores null, so host agents that
predate this keep working.
- `endReason` and `timeClaimed` are returned on every session response.
- The API writes the reasons it decides itself: `user` when a run nobody
has claimed is stopped, and `left_queue` when a waiting run is abandoned
(the `Left the queue` message is kept).

**New transition, `starting → ended`.** A run stopped while its game is
still downloading has failed at nothing, and never reached `live`, which
is where billing starts. Until now the only way out of `starting` was
`failed`.

**Release order: this goes first.** The host agent that sends
`endReason` is a separate change. This route's body schema is strict, so
without this deployed the current API answers that agent with a 400.

**Tests.**
- API: 185 pass, 5 of them new.
- Core: 41 session tests pass, 3 of them new.
- The new tests fail when the implementation is broken on purpose.
- No new type errors.
2026-10-16 00:20:49 +03:00
Wanjohi cf0606a81a chore(install): host agent 0.2.18 (#410)
Brings dev's install.sh to the 0.2.18 prod serves, so the next dev→prod
release cannot send installs backwards.
2026-10-16 00:16:02 +03:00
Wanjohi d565e6d445 fix(billing): every webhook says what it did, with the provider's ids (#406)
**Problem.** A billing webhook that changes nothing answers `200`
exactly like one that worked.
- A subscription made before checkouts carried the team id (`db43068`)
never matches a team.
- When one was cancelled, the API logged `a paid subscription matched no
team` with only the event type. Nothing said whose subscription it was,
so nobody could act on it.
- Deliveries that did apply were not logged at all.

**Changes.**
- `Polar.receive` returns the provider's `subscriptionId` and
`customerId`, in both camelCase and snake_case, as the rest of the
payload is read.
- For a `subscription.*` event, the event's own id is the
subscription's.
- For anything else, such as `order.refunded`, it is the
`subscription_id` field.
- The webhook route logs one line per delivery: `billing webhook {type,
team, standing, applied, subscription, customer}`.
- The unmatched-paid warning carries those ids and says what to do: find
the customer in the provider dashboard and set the team's plan by hand.
- A delivery naming a team that no longer exists is logged as an error,
not silently applied to nothing.

No behaviour change: the same deliveries apply to the same teams. This
is logging only.

**Tests.**
- `bun test src/billing/polar.test.ts`: 17 pass. One new test covers the
ids in both spellings, on a subscription event and on a non-subscription
one.
- `tsc` has no errors in the changed files. The existing errors are in
`utils/hook.ts` and `utils/validator.ts`, from Zod 4.
2026-10-15 19:31:36 +03:00
Wanjohi ebf31b5fe8 fix(build): the guest carries the libraries Proton's media plugins need (#405)
**Problem.** Proton's GStreamer plugins link `libvpx`, `speex`,
`libtheora`, `libvdpau` and `jsoncpp`, and neither Proton nor the guest
image carries them. Boxes log:

```
Failed to load plugin '.../gstreamer-1.0/libgstlibav.so': libvpx.so.12: cannot open shared object file
Failed to load plugin '.../gstreamer-1.0/libgstspeex.so': libspeex.so.1: cannot open shared object file
```

Wine's media stack then has no decoders, so a game whose videos go
through it plays them as nothing.

**Scope.**
- Not new. The stock `11.0-20261005` Proton and the patched `-n1` build
need exactly the same set.
- The fleet logged it on 2026-10-10 and again on 2026-10-15.

**Fix.** These five packages are added to the guest's runtime package
list.
- They were found by listing every `NEEDED` library of each published
Proton against this image; they were the whole gap.
- They track the sonames Proton was built against, as the new comment
says.

**Verification.** In progress: the image is building, and the library
check is rerun against it for both Protons.
2026-10-15 19:28:31 +03:00
Wanjohi 2d74cf73fd chore(install): dev serves the versions prod does (host agent 0.2.17, desktop 0.1.2) (#403)
Dev still said host agent 0.2.13 and desktop 0.1.0, while prod serves
0.2.16 and 0.1.2. The next dev→prod merge would have sent new installs
back three host releases. This brings dev to prod's numbers. No other
change: the rest of this branch is already on dev.
2026-10-15 11:53:14 +03:00
Wanjohi 424b13e277 fix: OpenGL games draw, Chromium games present, Proton makes non-ASCII file names (#401)
Games that showed a black screen with sound now render. Found by testing
on an AMD host: none of these were GPU-vendor specific.

**OpenGL games (Dome Keeper, Godot): fixed and confirmed.**
- `c017462`: the guest's Mesa had no EGL/GLX frontends, so a GL game had
nothing to load and never created a device. The image now ships them,
with glvnd's vendor file, so GL reaches zink and the capture layer sees
the frames.
- `5f2ccdf`: `vm.overcommit_memory=1`. The kernel default refused Dome
Keeper's 9 GiB reservation on an 8 GiB box before a byte was used.

**Chromium / NW.js games (Sheepy) stalled after one frame: fixed.**
- `a73c0c4`: nescope answers every commit with frame callbacks and
feedback, not only commits from a surface it maps to a window.
Chromium's GPU process draws for a window another process owns. Wine's
Wayland driver gives that surface no role, so its FIFO present waited
forever in the driver.
- `23ba9b5`: a window is matched by its surface, not its xdg toplevel,
so Xwayland windows are paced too.

**Proton could not create non-ASCII file names: patch added, build
pending.**
- `6770684`: Steam starts games with `LC_ALL=C`, and Wine reads that as
ASCII for every Unix file name. NW.js stops unpacking its app at
`media/• drum loop….webm` and shows an empty window.
- The patch reads an ASCII locale as UTF-8. Proton now takes patches
from `build/patches/proton/<submodule>/`, and a patched image is tagged
`-nN` (`11.0-20261005-n1`).
- Nothing points at that tag yet; hosts move to it in a separate change,
after it is built and tested.

Not covered: Metro 2033 Redux's report looks like the frame-callback
stall, but it was not retested.
2026-10-15 08:19:23 +00:00
Wanjohi 6770684e5c fix(build): let Proton's Wine create non-ASCII file names under Steam
Steam starts games with LC_ALL=C, and Wine reads the C locale as ASCII
for every Unix file name it makes, so a name like '• drum loop.webm'
could not be created at all. Sheepy, an NW.js game, unpacks its app from
a zip at start, stopped at that file, and showed NW.js's empty window.

Proton is now patched before it builds (patches/proton/<submodule>/), and
the first patch reads an ASCII locale as UTF-8. The image name gains -nN
for our patch level, so a patched build never takes the plain tag's name.
2026-10-15 10:56:29 +03:00
Wanjohi a73c0c4cfc fix(nescope): answer every commit, not only a mapped window's
A Vulkan client presenting FIFO waits in the driver for its frame
callback. Chromium's GPU process draws for a window another process
owns, which Wine's Wayland driver gives a surface with no role, so no
window here matched it and the game presented one frame and waited
forever: Sheepy's black screen. Nothing here paces against a display,
so every commit now gets its frame callbacks and feedback.
2026-10-15 10:28:44 +03:00
Wanjohi 23ba9b5cae fix(nescope): send frame callbacks to Xwayland windows
Since frame callbacks moved onto the commit path, the commit handler
found a window by its xdg toplevel. An Xwayland window has none, so no
Proton game got a frame callback or presentation feedback. Xwayland then
completes a flip only on its one-second fallback timer, and a game
presenting FIFO stalls in the driver after its first frame.
2026-10-15 10:03:44 +03:00
Wanjohi 5f2ccdfbf2 fix(nesinit): let a game reserve more address space than the box has RAM
The kernel's default overcommit policy refuses any single allocation
larger than RAM plus swap. A box has no swap, so the ceiling is the
box's own size, and an engine or driver that reserves address space it
never touches is refused before it uses any of it. Dome Keeper asked
for 9 GiB on an 8 GiB box and never started.

Set vm.overcommit_memory=1 alongside vm.max_map_count, so a request is
granted and the OOM killer acts on what is actually used. In a box that
is the game: init has already exempted itself.
2026-10-15 00:41:30 +03:00
Wanjohi c0174628cd fix(build): give the guest's OpenGL driver its EGL and GLX frontends
Mesa was built with zink as its only Gallium driver and every frontend disabled, so a game rendering with OpenGL found no GL and showed a black screen. EGL, GLX, GBM and GLES2 are enabled, with the x11 platform for Xwayland clients.
2026-10-14 11:23:35 +03:00
Wanjohi 1301ff0806 fix(billing): a returning customer's subscription reaches their team
The provider reuses a customer it already has under the same email and
leaves our team id off it, so the subscription arrived with no team,
was acknowledged, and changed nothing: a paid plan never applied. The
checkout now carries the team in its metadata, which the subscription
keeps, and the webhook reads it there when the customer has none. A
paid delivery that still matches no team is logged instead of dropped
in silence.
2026-10-13 21:37:22 +03:00
Wanjohi 24ad9f6b78 fix(api): a Steam sign-in on any host links the account
A sign-in on a person's own host recorded the enrolment but never
linked the Steam account, so their first launch asked them to link it
again with nothing in the app to do it from. The QR sign-in is the same
proof on their host as on ours.
2026-10-11 17:33:18 +03:00
Wanjohi 84bd6b899a feat(install): the desktop app downloads from a link that never changes
/download/windows and /download/linux redirect to a one-minute signed URL
for the current desktop app, from the same private bucket as the host
agent, so a download page can link to them once and every download still
passes through the API. Which version they hand out is a constant bumped
by a commit after a release is published, as the host installer's is.

/desktop.sh installs it on Linux for the calling user: one binary in
~/.local/bin and a launcher entry, no root.
2026-10-11 14:13:44 +03:00
Wanjohi 122fc12f86 chore(install): host agent 0.2.13
(cherry picked from commit 3d381673f8)
2026-10-11 14:07:40 +03:00
Wanjohi 6a9f675b9f feat(session): runs wait in line for a free slot on their host
A host holds only so many runs at once, and until now every waiting run
was handed to it regardless; the ones it could not hold failed there.

The host now says on its heartbeat how many runs it holds, and is handed
only as many waiting runs as it has free. The rest wait in line: paid
runs before free-weekend runs, then first come first served. Reading a
waiting run returns its place in line, and says when a paid run that
asked later has gone ahead, so a free player is never reordered quietly.

A waiting run nobody has asked after for a minute was abandoned, and
leaves the line rather than holding up everyone behind it. A host that
does not report its slots is handed every run, as before.
2026-10-11 13:44:18 +03:00
Wanjohi 4627343e3c feat(trial): the free days are Saturday, Sunday and Monday
Monday in place of Friday: the window is Saturday 00:00 to Tuesday 00:00,
Helsinki time, and the refusal outside it says Saturday to Monday.
2026-10-11 13:22:22 +03:00
Wanjohi aa824a1905 feat(box): a listed box names its machine's host and whether it is the fleet
The desktop app gives a new cloud user a fleet box and then opens that
machine's Steam sign-in on its hostname; GET /box now says which hostname,
and which box is the fleet one.
2026-10-11 12:08:07 +03:00
Wanjohi 4fc93ca4aa chore(install): host agent 0.2.12 2026-10-10 20:50:54 +03:00
Wanjohi 9269ee5d51 fix(neshub): an encoder that missed the codec choice is told again
The codec the clients agree on goes to the encoder over a socket that does
not exist until the game draws, and a client can connect first. The command
was dropped, the encoder started on AV1, and a client without AV1 sat on a
splash with sound and no picture. Commands now wait for the socket, and a
frame in a codec no client decodes sends the agreed set again, which also
covers a launcher handing over to a game with an encoder of its own.
2026-10-10 15:34:23 +03:00
Wanjohi ac713e62ae feat(billing): sizes move up one; 1080p is the reference and the default
xs is a box with no stream, sm 720p, md 1080p, lg 1440p and xl 4K. Each
resolution keeps its price, so md is now 1x. New boxes default to md, and
existing boxes move up one size so none of them drops a resolution.
2026-10-10 15:18:23 +03:00
Wanjohi 0f38898fd9 fix(session): commit the migration that adds a run's size 2026-10-10 15:13:54 +03:00
Wanjohi 8608506682 feat(session): a run picks its own size
POST /session takes an optional tier. The host starts the run at that
size, the allowance is checked against it, and the run is billed at it.
A run asked for without one runs at its box's size, as before.

The size belongs to the run rather than the box: a box is a game's
install on a machine, and the size is chosen each time it is played, so
playing bigger never means a second box and a second install.
2026-10-10 14:03:13 +03:00
Wanjohi 05a3fee87a feat(session): list your runs that have not stopped
Asking for a run on a busy box is refused, but the refusal does not say
what holds the box. A client that just lost its connection needs to know
whether the run in the way is the game it asked for, which it can rejoin,
or another one it should offer to stop. GET /session answers that.
2026-10-10 11:19:20 +03:00
Wanjohi 152337296d fix(library): downloads only from your boxes' hosts, named by their GPU
GET /library picked each game's most recent download row from any host,
so one person's library could show another person's machine id and
progress. It now reads only hosts the caller's boxes are on.

Boxes and library downloads gain `gpu`, the host's first reported card
model, so a client names a place by hardware a person recognises rather
than by a machine id. Null until a host reports one.
2026-10-10 10:03:49 +03:00
Wanjohi 77595ecb65 chore(install): host agent 0.2.11 2026-10-09 13:29:58 +03:00
Wanjohi 8054e62bd9 feat(machine): let the owner read what their machine has downloaded
GET /machine/:id/downloads lists every game the machine is downloading,
has ready or failed to download, with progress and each game's name.
The library already carries a download per game, but it is the most
recent one across every host, which is not what a person watching their
own machine set up wants to see.

Owner-scoped in the query: a machine that is not yours answers 404, the
same as one that does not exist.
2026-10-09 12:50:44 +03:00
Wanjohi 7670988ece feat(lifecycle): tell the host how many clients are watching
A host cannot tell a run somebody is playing from one nobody has looked
at in an hour, so it cannot end the second without guessing.

The hub now answers the address socket with a second line,
`viewers <n>`, counting clients that are streaming. Init reads it
alongside the address and sends a new `viewers` message to the host
when the count changes. A reader that takes only the first line gets
the address exactly as before, a server that never writes the second
line is still read, and a host that does not know the message skips it,
so either end can ship first.
2026-10-09 12:33:43 +03:00
Wanjohi f0bfe82d58 feat(session): let the owner end a run
POST /session/:id/stop. A run no host has claimed ends at once; one that
is starting or live is marked with time_stop_requested and its host is
handed a session.stop with reason `user` on every poll until it reports
the run ended. The host already ends a run the same way whatever the
reason, so no host change is needed.

Asking twice keeps the first time asked, and asking of a finished run
changes nothing. Somebody else's run answers 404, and a host cannot
stop a run as its owner.
2026-10-09 12:27:09 +03:00
Wanjohi 2ca3229d57 chore(install): host agent 0.2.10 2026-10-09 11:56:43 +03:00
Wanjohi 3fbcf459f2 machine: a host reports its GPUs, and the machine keeps them
The host report gains gpus: each card's model, VRAM in MiB when the
driver says, and the PCI id it was named by. Kept on the machine (a jsonb
column, written only when it changes) and returned by /machine. What a
person calls the machine, and the first measured part of the capacity a
scheduler will place by.
2026-10-09 11:37:40 +03:00
Wanjohi 3975de5027 install: host agent 0.2.9 2026-10-09 10:33:19 +03:00
Wanjohi ba9191825c library: a sync drops the games the account no longer owns
A sync is the whole of what the account owns, but it only ever upserted,
so a revoked licence or a library from a previous Steam account stayed
listed forever. A sync that fully lands now soft-deletes the rest; an
empty list prunes nothing, and a game owned again comes back.
2026-10-08 12:19:05 +03:00
Wanjohi 4445f4836e api: accept the host's own tier label in a report
The report schema pinned each box's tier to our size enum, but hosts name
a box's shape by resolution (1080p). Every report naming a box was a 400,
so stop reasons never reached the run. Placement already stores the size;
the reported label is free text and is not stored.
2026-10-08 12:14:46 +03:00
Wanjohi 56e64d4ba8 install: host agent 0.2.8 2026-10-08 11:10:59 +03:00
Wanjohi a939defd5e install: host agent 0.2.7 2026-10-08 10:29:09 +03:00
Wanjohi 2fb59f4dd8 install: host agent 0.2.6 2026-10-08 09:58:43 +03:00
Wanjohi d89d73c263 install: host agent 0.2.5 (#368)
Pins the installer to host agent 0.2.5.
2026-10-08 06:37:58 +00:00
Wanjohi 514212bbbe Host agent runs as root; NVIDIA host programs ship in the base image (#366)
- **Installer:** piped into `sudo`, installs to `/usr/local/bin`, and
hands onboarding `/var/lib/nestri`. Root lets the host agent jail each
box under its own uid and give guests a network. A host that ran the
agent as the sudo user has that state copied over (never moved), so it
keeps its machine identity and its downloads. Pins host agent 0.2.4.
- **Base image:** `vhost-user-nvgpu` and `nvgpu-userspace`, static
(musl), from the same `NVGPU_REF` as the guest driver and Vulkan layer,
under `/boot/host`. The host agent takes them out at unpack, as it does
the kernel, and forwards the GPU when it finds the NVIDIA driver loaded.

Merge after the host agent 0.2.4 release: the installer pins it.
2026-10-07 19:59:29 +00:00
Wanjohi 7481150e97 fix(install): stop a running host agent before upgrading it 2026-10-07 19:09:01 +03:00
Wanjohi b0a42b4c37 fix(install): fetch the glibc host build, which is what releases publish from 0.2.3 2026-10-07 19:05:23 +03:00
Wanjohi 8520ddf6c5 install.sh: install host agent 0.2.3 2026-10-07 18:48:46 +03:00
Wanjohi 4e4dd8896f build: ship the guest kernel inside the image, publish from the build box (#361)
A `kernel-build` stage runs `kernel-build.sh` and puts the result at
`/boot/vmlinux`; host onboarding takes it out beside the root image, so
kernel and image (and their shared NVGPU_REF) always ship as one pair.
`make push` publishes `:latest` and a commit tag from the build box.

Built on the build box: `/boot/vmlinux` is 24 MB, and
`vmlinux.nvgpu-rev` is beside it.
2026-10-07 15:43:31 +00:00
Wanjohi 5bf520c464 Name the run's owner on session.start (#362)
Adds `userId` (the box owner) to `session.start` so a host knows whose
Steam sign-in plays the run. API tests: 161 pass.
2026-10-07 15:43:25 +00:00
Wanjohi 27dc33f9ab Link the Steam account when a fleet machine reports a sign-in (#360)
A QR sign-in on Nestri's own hardware proves the Steam account, so `POST
/machine/enrolment` from a machine in `FLEET_ORGANISATION_ID` also links
it to the person. Hosts somebody brought never link (a machine vouching
for itself could attach any Steam account to anyone enrolled on it). A
Steam account already linked elsewhere stays there; the report is
recorded either way.

Tests: three new in `enrolment.test.ts` (fleet links and is idempotent,
own host does not link, taken account is left alone). 164 pass.
2026-10-07 15:43:20 +00:00
Wanjohi 89fc7592e6 fix(install): install host agent 0.2.2 2026-10-07 11:03:56 +03:00
Wanjohi 543fd3f361 fix(steam): link a Steam account only through Steam's own sign-in
POST /steam/link took a Steam id from the request body and linked it. A
Steam id is public, so anyone could claim any account, its library, and
the one free weekend that is keyed on it.

Linking is now POST /steam/link/start, which returns a Steam sign-in URL
tied to a one-time, ten-minute request, and GET /steam/link/callback,
where the browser comes back. The callback accepts the id only if Steam
confirms the assertion, the assertion was issued for exactly this
callback, and the id is read from Steam's claimed identity URL in the
one shape Steam issues it. The request is spent on use.
2026-10-07 10:52:19 +03:00
Wanjohi 69ad5d09bb fix(install): install host agent 0.2.1, which ends a run the control plane stops 2026-10-07 10:25:02 +03:00
Wanjohi 6fee7f86e6 Merge branch 'feat/fleet-trial' into dev 2026-10-07 09:43:44 +03:00
Wanjohi d92bc13ed9 feat(machine): an install command that adds a host to the fleet
An install token can now name an organisation instead of a team, and the
host it registers belongs to that organisation's fleet. Only members of
the organisation may mint one, since fleet hardware is rented to other
people. A token for both is refused, by the route and by a check
constraint, as it already is for a machine.
2026-10-07 09:42:51 +03:00
Wanjohi 47f8ff6f61 feat(trial): a free weekend on rented GPUs, for teams without a plan
A team with no plan may run on the fleet from Friday 00:00 to Monday
00:00, Helsinki time, for two hours of live play per weekend. One trial
per team, per email address and per Steam account; the claim outlives
the account, so closing it and signing up again is not a second trial.
Once five teams pay, the trial closes for everyone.

A trial run is marked on the session. Its time is counted from the run's
own live interval and kept out of the plan's allowance. Unlike an
allowance, the trial ends a run: the host's work poll now carries a
session.stop job for any trial run past its hours or its window, which
an agent that does not know the kind already skips.
2026-10-07 09:30:28 +03:00
Wanjohi 2da95deb36 feat(billing): bill a run on rented hardware to the box owner's team
A fleet machine belongs to an organisation, which is not something that
pays, so fleet runs were neither checked nor metered. The payer is now
the box owner's personal team, and the meter that starts and stops with
a run asks the same function the start check does, so the team checked
and the team billed cannot differ.
2026-10-07 09:25:32 +03:00
Wanjohi 08bbb22a1c feat(machine): rented hardware is open during a run you asked for
Fleet machines refused everyone, because nothing could yet say who had
paid for them. A run on one can only exist once it passed the start
check, so a starting or live run of your own box on that machine is now
what grants access, and it ends with the run. Holding a box is not
enough: a box costs nothing to hold.
2026-10-07 09:24:18 +03:00
Wanjohi f82666254e feat(box): create a box, on your own host or on rented GPUs
POST /box places a box once, at creation. On your own host it keeps the
existing rule (exactly one host, or refuse). On the fleet it picks an
online machine owned by the fleet organisation, the one holding the
fewest boxes. Each person holds at most one fleet box. Which organisation
is the fleet is deploy configuration, so no deployment rents hardware it
was not told to.

No machine online is a 429, since the same request succeeds once one is
back.
2026-10-07 09:23:12 +03:00
Wanjohi 9d7239a280 Merge branch 'feat/team-and-box-reads' into dev 2026-10-07 08:49:07 +03:00
Wanjohi 8c61cb5cbb feat(build): put virtio-nvgpu's Vulkan layer into the guest rootfs (#356)
On an NVIDIA host, NVIDIA's Vulkan driver in the guest lists ray
tracing, optical flow, binary import and CUDA kernel launch, then fails
`vkCreateDevice` with `VK_ERROR_INITIALIZATION_FAILED` for each of them.
An engine that enables what it is offered does not start. virtio-nvgpu's
guest layer (`vklayer/`, nestrilabs/virtio-nvgpu#5) hides those
extensions. It acts only on an NVIDIA device in a guest that has the
virtio-nvgpu driver, so on AMD and Intel boxes it loads and does
nothing.

- A new `nvgpu-layer-build` stage builds the layer from virtio-nvgpu at
`NVGPU_REF`, the same setting the kernel build uses for the guest
driver. The commit is recorded in `/usr/share/nestri/virtio-nvgpu.rev`.
- The layer and its manifest go in `/usr/lib` and
`/usr/share/vulkan/implicit_layer.d`. The missing-library check covers
the layer.

**Merge after nestrilabs/virtio-nvgpu#5.** The default `NVGPU_REF=dev`
has no `vklayer/` until that PR lands, so `make build` fails at this
stage before then.

Built on nestripc-1 with `NVGPU_REF=feat/v0.2-compute` (virtio-nvgpu
112eac4) and pushed:
- `ghcr.io/nestrilabs/nestri/base:latest` sha256:152a4485…
- `ghcr.io/nestrilabs/nesbox/jail:latest` sha256:df813051…, rebuilt on
that base from nesbox dev 6d37597, which includes the vhost-user
forwarding

Measured on box2 (RTX 3060, 595.104.02) with virtio-nvgpu's vkrt probe:
with the layer on, a plain device is created and none of the seven
extensions are offered. With it off, all seven are offered and fail with
-3.
2026-10-04 02:30:10 +00:00
Wanjohi 01d6f2302d build: put virtio-nvgpu's Vulkan layer into the guest rootfs
On an NVIDIA host the guest's Vulkan driver offers ray tracing, optical
flow and a few others it cannot create a device with, and an engine that
enables them does not start. virtio-nvgpu's layer hides them; it acts
only on a forwarded NVIDIA device, so AMD and Intel boxes are unaffected.

Built from virtio-nvgpu at NVGPU_REF, the ref the kernel build already
takes the guest driver from, and recorded in the image.
2026-10-02 22:43:03 +03:00
Wanjohi 55aa08b673 fix(auth): sign-in screen polish, and a local preview of every state (#355)
Polishes the sign-in screens and adds a way to see all of them locally.

**Screens**
- Code fields are drawn as one box per character (device code shows a
dash between halves). One real input sits under the boxes, so typing,
paste and autofill work normally, and with script off it falls back to
the plain field.
- A wrong answer or a refused character shakes the field and rings it
red.
- Focus is white instead of the brand colour; the button stays dim until
the form can be sent.
- One alert at a time: an error replaces "Code sent to …" instead of
joining it.
- The device confirm screen says "A device" instead of the client's
self-chosen name, with the Deny warning moved under the buttons.
- A device code typed with its dash (`BCDF-3467`) is no longer cut to
`BCDF-346`.

**Preview**
`bun run preview` in `apps/auth` serves all 18 screen states on :1338,
at desktop and phone sizes, reloading on save. No database or mail
needed.

To make that possible the issuer's screens moved out of the route
handlers into `packages/auth/src/ui/issuer.ts`, unchanged.

Tests: `bun test` in `packages/auth` (67 pass).
2026-10-02 03:13:34 +00:00
Wanjohi 1f82954037 style(auth): ring a refused or wrong answer red while it shakes
A code field silently dropped a character it does not accept, so typing
a letter into the emailed code, which is digits only, looked exactly
like a field that does not work. It now shakes the boxes and rings them
red, the same answer a wrong code gets. Dashes and spaces are still
taken quietly, since they are how a code is written.

The shake an error page plays on load now rings the field red too,
fading back to its usual border once it settles, on every field
including the email address. Reduced motion keeps the red and drops
the movement, since the red is the message.
2026-10-02 06:10:05 +03:00
Wanjohi 890ada8ead fix(auth): type code boxes through one real field
The boxes were one input each, with script moving focus between them.
That worked in a clean browser and failed in an ordinary one: on the
emailed code, whose first box is marked as a one-time-code field, typing
and pasting did nothing, while the device code, which is not marked,
worked. A password manager or autofill prompt hooking that field and a
script moving focus between six fields were working against each other.
It also left gaps: pasting into the third box with the second one empty
produced a code with a character missing.

Now the real input is stretched invisibly over the boxes and is the only
thing typed into, pasted into or autofilled; the boxes only display what
it holds, with the next one ringed and showing a caret. With nothing
moving focus there is nothing to fight, and a code cannot have a gap.
The browser's own validation message works again too, since the field
it points at is where the boxes are.
2026-10-02 06:03:48 +03:00