yamine 0.20.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c48a7ce222b3ffe113f542e81d84aca45270e4c3ea4f4f7da74bd22e8d0747d7
4
- data.tar.gz: 8a8c1ad0f17b8bd741723cb9d83100248c559f5c7c995d64f30abfcebfe4fa60
3
+ metadata.gz: 8e920f5f6d29a92e0a6e81ca671bccf62e0a264ec453f9b5bc4d42d4e56980d3
4
+ data.tar.gz: cc06342930ee7c882cf30fd2818f1a60495c8a59e44ec6779cc666f569493ac0
5
5
  SHA512:
6
- metadata.gz: c9c3ae18ac29e5a30b378d7b70c7dd6c538b9140b058f4308ee2d9e5075567cde9ea21d169d30c54808d6a06a4283d3454d2a652b4b254b40e406d2de4a7edbc
7
- data.tar.gz: 594bf48948e649c390aa3aed20246abd1c0eabbd0889f2a159f3b4a35d77b0d8cf8874b1eb1f8ebb51be9b0dc4300879139b943a35bde12fbc421a789b08f809
6
+ metadata.gz: 4164dabf9bd5537023ad59d426a958a5e284a90f0226cc83e0533cf635edf0c65acc16dd311d58a88df83d7abdcdfb92034703ecda06d89af08bb5ec96c73635
7
+ data.tar.gz: 1e3af2b55e54885fe8aeed98856c4371eab280ec981cd2644ebb75aba0206752ae44307c0252f51188a778426efbb882ef2285c63f142def39f4e876f77302d6
data/CHANGELOG.md CHANGED
@@ -2,6 +2,105 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.21.1] — 2026-09-29
6
+
7
+ ### Fixed
8
+
9
+ - **Claiming a per-worktree database no longer raises `NoMethodError`
10
+ on Ruby 3.2/3.3.** The claim stamps the state file with
11
+ `Time#iso8601`, but `lib/yamine/database.rb` never required the
12
+ stdlib `time` library, and on 3.2/3.3 nothing else loads it
13
+ implicitly — so every claim raised (34 unit errors on those rubies
14
+ in CI). 0.21.0 is affected. One `require "time"`, alongside the
15
+ stdlibs that file already pulls in.
16
+ - **A closed WebSocket upgrade no longer leaks a thread on Linux.**
17
+ `pipe_both` tore each end down with `close` from the sibling
18
+ thread, and a close from another thread does not interrupt that
19
+ copy's blocking read on Linux (it does on macOS) — so every closed
20
+ upgrade parked a thread forever, `handle` never unwound, and they
21
+ accumulated in the root daemon. Teardown is now symmetric
22
+ `shutdown(2)`, which wakes the parked read with EOF; both sockets
23
+ still close exactly once, and no timeout was added to the
24
+ unbounded upgrade path.
25
+
26
+ ## [0.21.0] — 2026-09-28
27
+
28
+ ### Security
29
+
30
+ - **The privileged service no longer runs user-writable code.** The
31
+ launchd/systemd unit used to execute the gem's `bin`+`lib` straight
32
+ out of the user's home (a version-stamped, user-writable gem
33
+ directory), so anything that could write those paths — a gem upgrade,
34
+ a `bundle install`, an agent editing the gem — ran as root at every
35
+ boot. `service install` now stages `bin`+`lib` into a root-owned
36
+ directory at a stable, version-independent path (`/Library/Application
37
+ Support/yamine` on macOS, `/usr/local/share/yamine` on Linux):
38
+ copied aside, ownership/modes locked, verified fail-closed (a bad
39
+ tree registers no unit and leaves the running daemon untouched), then
40
+ swung live with an atomic symlink swap plus a root-owned `VERSION`
41
+ marker. The unit pins only that path.
42
+ - **The passwordless grant can no longer introduce root-executed
43
+ code.** `yamine sudoers` used to grant the version-stamped gem path
44
+ with `service install --internal` — effectively arbitrary code as
45
+ root for anyone who could write the gem, and stale after every
46
+ release. It now grants only the staged, version-independent payload:
47
+ `hosts sync` (the recurring agent need) and `service uninstall`
48
+ (which only deletes yamine's own files). Staging new root code stays
49
+ a human interactive sudo; a non-interactive `service install` fails
50
+ fast saying so. If your `/etc/sudoers.d/yamine` still pins a
51
+ version-stamped gem path, re-run `yamine sudoers` and replace it.
52
+ - **`hosts sync` is strictly validated before anything is written.**
53
+ Route names are user-writable and the sync runs as root, so every
54
+ name must now be well-formed (no newline/breakout), written only
55
+ inside yamine's managed block, and under `.localhost` or a domain in
56
+ the root-owned staged allowlist. Anything else fails closed naming
57
+ the policy. Custom `proxy.host` domains need one human step: append
58
+ the parent domain to the staged `allowed-tlds` file.
59
+ - **`yamine doctor` gains a `service` check.** It names a legacy unit
60
+ (payload in a user home or version-stamped gem path) with the exact
61
+ migration (`sudo yamine service install`), warns on staged-vs-CLI
62
+ version skew, fails on any root-executed path that is not root-owned
63
+ or is group/other-writable, and always reports the service
64
+ interpreter's path and writability. The legacy daemon keeps working
65
+ until the next elevated install.
66
+
67
+ ### Changed
68
+
69
+ - `service install` (human, interactive, elevated) migrates a legacy
70
+ install in place and prints the interpreter caveat; `service
71
+ uninstall` removes the staged payload as well as the unit, scoped to
72
+ yamine's own files.
73
+ - Non-interactive (`sudo -n`) failure messaging points at the new
74
+ provisioning story: humans install the 443 service once per machine,
75
+ agents keep hosts entries fresh through the grant.
76
+
77
+ ### Fixed
78
+
79
+ - **The printed grant now matches what the CLI invokes, byte for
80
+ byte.** Elevated re-execs went through `env YAMINE_STATE_DIR=…`,
81
+ which made the command `/usr/bin/env` — a path no rule names — so
82
+ every passwordless attempt fell through to the password-required
83
+ admin rule; and the staged macOS path's space was unescaped in the
84
+ sudoers output, so that rule could never match either. No
85
+ environment crosses sudo anymore (no `env` prefix, no SETENV): the
86
+ root half derives the invoking user's state dir from `SUDO_USER`,
87
+ and spaces are backslash-escaped when the rules are printed. Pinned
88
+ by a parity test that parses the printed specs and asserts equality
89
+ with the captured elevated argv.
90
+ - **The `--no-service` sudo daemon no longer executes the gem
91
+ directory as root.** A privileged `proxy start` spawns the staged
92
+ root-owned payload, staging it first under the same
93
+ human-authorized sudo (`service stage --internal`, never granted)
94
+ when missing. The unprivileged spawn path is unchanged.
95
+
96
+ ### Open caveat (not closed, stated plainly)
97
+ - **The interpreter is still user-writable.** No root-owned Ruby ≥ 3.2
98
+ exists on a stock machine, so the daemon necessarily runs the
99
+ invoking (user-writable) Ruby. The payload is root-owned; the
100
+ interpreter is not. Doctor reports it truthfully and the README says
101
+ so. Fixed only by a root-owned Ruby new enough for the gem; yamine
102
+ will not vendor or stage an interpreter.
103
+
5
104
  ## [0.20.0] — 2026-09-28
6
105
 
7
106
  ### Added
data/README.md CHANGED
@@ -54,21 +54,35 @@ to get back to 443.
54
54
 
55
55
  Binding 443 is privileged, so yamine installs a **root-owned launchd
56
56
  service** (macOS) or systemd unit (Linux) that binds 443 at boot — the
57
- same model as puma-dev and portless. Installing it needs sudo **once per
58
- machine**; after that, every `yamine` run in any project gets a clean
59
- `https://<app>.localhost` with no elevation and no prompt.
57
+ same model as puma-dev and portless. Installing it needs an interactive
58
+ sudo **once per machine** (one Touch ID tap); after that, every `yamine`
59
+ run in any project gets a clean `https://<app>.localhost` with no
60
+ elevation and no prompt.
60
61
 
61
- **Human (interactive):** run setup once — it trusts the CA, installs the
62
- service, syncs hosts, and verifies:
62
+ **Human (interactive):** run setup once — it stages a root-owned copy of
63
+ yamine, installs the service, trusts the CA, syncs hosts, and verifies:
63
64
 
64
65
  ```bash
65
66
  yamine setup
67
+ # or, for just the service: sudo yamine service install
66
68
  ```
67
69
 
68
- **Agent / CI (no TTY):** the same commands fail fast with guidance,
69
- because sudo needs a terminal. To pre-provision a machine or image so
70
- agents can install the service without a prompt, install the scoped
71
- passwordless-sudo rules once (as an admin):
70
+ The unit runs a **staged payload**, not your gem directory: the install
71
+ copies yamine's `bin`+`lib` into a root-owned directory at a stable,
72
+ version-independent path (`/Library/Application Support/yamine` on
73
+ macOS, `/usr/local/share/yamine` on Linux), verifies root ownership and
74
+ modes fail-closed (a bad tree registers nothing), and pins that path in
75
+ the unit. Ordinary user-space writes — a gem upgrade, a `bundle
76
+ install`, an agent editing the gem — can no longer change what runs as
77
+ root, and upgrades never stale the setup. `yamine doctor` names a legacy
78
+ install (a unit still pointing at a user-writable gem path) with the
79
+ exact migration, plus staged-vs-CLI version skew and any ownership
80
+ problem.
81
+
82
+ **Agents: the steady-state grant.** New project ⇒ new hostname ⇒ Safari
83
+ needs the `/etc/hosts` entry; that recurring privileged need stays
84
+ agent-invocable without a prompt. Install the scoped passwordless-sudo
85
+ rules once (as an admin):
72
86
 
73
87
  ```bash
74
88
  yamine sudoers > /tmp/yamine.sudoers
@@ -76,11 +90,41 @@ sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine
76
90
  sudo install -o root -g root -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine # Linux
77
91
  ```
78
92
 
79
- `yamine sudoers` prints rules scoped to yamine's own service
80
- re-exec — the gem's exact ruby + bin path with the `service install
81
- --internal` / `service uninstall --internal` subcommands — never a bare
82
- interpreter. Re-run it after upgrading the gem if the install path
83
- changes. To undo: `sudo rm /etc/sudoers.d/yamine`.
93
+ `yamine sudoers` prints rules for the **staged payload only** — `hosts
94
+ sync` (hostnames strictly validated: well-formed, written only inside
95
+ yamine's managed block, and only under `.localhost` or an allowlisted
96
+ domain) and `service uninstall` (which only deletes yamine's own
97
+ files). Installing or upgrading the staged payload moves user-writable
98
+ source into root-owned paths, so it deliberately stays a human
99
+ interactive sudo — no grant will ever cover it, and a non-interactive
100
+ `service install` fails fast saying so. To undo the grant:
101
+ `sudo rm /etc/sudoers.d/yamine`.
102
+
103
+ If you installed an older yamine, your `/etc/sudoers.d/yamine` may still
104
+ pin a version-stamped gem path (`.../gems/yamine-X.Y.Z/...`) including
105
+ an install rule — re-run `yamine sudoers` and replace the file. The old
106
+ rules go stale every release (which pushed people toward `NOPASSWD:
107
+ ALL`); the new ones survive upgrades.
108
+
109
+ **Custom domains** (e.g. `proxy.host: myapp.local.example.com` for OAuth
110
+ parity) need their parent domain allowlisted once by a human — `hosts
111
+ sync` refuses anything outside `.localhost` and the staged allowlist,
112
+ because an unvalidated root hosts write could point a real vendor domain
113
+ at loopback:
114
+
115
+ ```bash
116
+ echo 'local.example.com' | sudo tee -a "/Library/Application Support/yamine/allowed-tlds" # macOS
117
+ echo 'local.example.com' | sudo tee -a /usr/local/share/yamine/allowed-tlds # Linux
118
+ ```
119
+
120
+ **Open caveat: the interpreter.** The unit runs the invoking Ruby, and
121
+ there is no root-owned Ruby ≥ 3.2 on a stock machine (`/usr/bin/ruby` is
122
+ 2.6), so the daemon necessarily runs a user-writable interpreter today.
123
+ The payload is root-owned; the interpreter is not — `yamine doctor`
124
+ reports its path and writability truthfully. Anything that can write
125
+ that Ruby can change what runs as root. This hole stays open until the
126
+ machine has a root-owned Ruby new enough for the gem; yamine will not
127
+ vendor or stage an interpreter to pretend otherwise.
84
128
 
85
129
  ## The one-file model
86
130
 
@@ -464,10 +508,11 @@ bundle exec rake test
464
508
  self-signed, and there is no buffering or rate limiting.
465
509
 
466
510
 
467
- The `yamine-apps` fixture fleet (sibling checkout) exercises
511
+ The `test/fixtures/apps/` fixture fleet exercises
468
512
  detection, inference, and boot across Rails variants, Roda, Sinatra,
469
- bare Rack, Jekyll, compound Procfiles, and a monorepo. CI runs the
470
- fixture sweep automatically.
513
+ bare Rack, Jekyll, compound Procfiles, and a monorepo. It ships with
514
+ the repo and runs as part of the unit suite across the Ruby matrix —
515
+ no sibling checkout, no separate CI job.
471
516
 
472
517
  ## License
473
518
 
@@ -940,10 +940,11 @@ module Yamine
940
940
  privileged = port < 1024 && !ProxyControl.root?
941
941
  if privileged && !ctx.interactive?
942
942
  $stderr.puts "Error: proxy is not running and port #{port} needs root."
943
- $stderr.puts " Human: run this once — yamine setup"
944
- $stderr.puts " Agent/CI: pre-provision passwordless sudo once —"
945
- $stderr.puts " yamine sudoers > /tmp/yamine.sudoers"
946
- $stderr.puts " sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine"
943
+ $stderr.puts " Human: run this once in a terminal — yamine setup (or: sudo yamine service install)"
944
+ $stderr.puts " Agent/CI: the 443 service is installed by a human once per machine — it cannot be provisioned passwordlessly."
945
+ $stderr.puts " Steady-state hosts sync works via the grant instead:"
946
+ $stderr.puts " yamine sudoers > /tmp/yamine.sudoers"
947
+ $stderr.puts " sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine"
947
948
  $stderr.puts " Or start the proxy by hand: sudo yamine proxy start"
948
949
  exit 1
949
950
  end