Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
description = "no-mistakes development environment";

inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};

outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
in
{
# go.mod requires `go 1.25.0`; go_1_25 keeps this dev shell in sync
# with that toolchain instead of relying on the machine's own PATH.
devShells.default = pkgs.mkShell {
buildInputs = [ pkgs.go_1_25 ];
};
}
);
}
21 changes: 19 additions & 2 deletions internal/daemon/selfexec.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,29 +167,46 @@ func reinstallManagedServiceIfChanged(p *paths.Paths) (bool, error) {
// with "403 Request not allowed". This mirrors the executable inheritance
// below: prefer the current environment, fall back to what is on disk.
var inheritedProxyEnv [][2]string
var inheritedShell string
if readErr == nil {
switch runtimeGOOS {
case "darwin":
if existingExe, ok := launchAgentExecutable(existing); ok {
renderedExecutable = existingExe
}
inheritedProxyEnv = launchAgentProxyEnv(existing)
if existingShell, ok := launchAgentShell(existing); ok {
inheritedShell = existingShell
}
case "linux":
if existingExe, ok := systemdUnitExecutable(existing); ok {
renderedExecutable = existingExe
}
inheritedProxyEnv = systemdUnitProxyEnv(existing)
if existingShell, ok := systemdUnitShell(existing); ok {
inheritedShell = existingShell
}
}
}
proxyEnv := serviceProxyEnv()
if len(proxyEnv) == 0 {
proxyEnv = inheritedProxyEnv
}
// Prefer the freshly resolved shell, but never let a degraded resolution
// (this call itself running in a restricted environment - see
// installShellIsDegraded) overwrite an already-installed, previously
// working absolute SHELL: that would falsely detect drift and
// reinstall+restart the daemon back onto a broken PATH. See
// resolveInstallShell.
shell := resolveInstallShell()
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if installShellIsDegraded(shell) && inheritedShell != "" {
shell = inheritedShell
}
switch runtimeGOOS {
case "darwin":
wanted = renderLaunchAgentWithProxyEnv(renderedExecutable, p, home, proxyEnv)
wanted = renderLaunchAgentWithProxyEnv(renderedExecutable, p, home, shell, proxyEnv)
case "linux":
wanted = renderSystemdUnitWithProxyEnv(renderedExecutable, p, home, proxyEnv)
wanted = renderSystemdUnitWithProxyEnv(renderedExecutable, p, home, shell, proxyEnv)
}
switch {
case readErr == nil && string(existing) == wanted:
Expand Down
36 changes: 36 additions & 0 deletions internal/daemon/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"testing"

"github.com/kunchenguid/no-mistakes/internal/paths"
"github.com/kunchenguid/no-mistakes/internal/shellenv"
)

// Base identifiers for the managed-service artifacts. The live identifiers
Expand Down Expand Up @@ -42,6 +43,41 @@ var serviceManagerBypassed = defaultServiceManagerBypassed
var prepareManagedDaemonLaunch = managedDaemonLaunch
var inspectManagedDaemonService = managedDaemonServiceState

// resolveInstallShell resolves the login shell to bake into a generated
// service unit's environment as SHELL, at install/render time - never inside
// the running managed daemon itself. A daemon started by systemd/launchd
// inherits only a minimal environment (HOME, a curated PATH, proxy vars; see
// proxyEnvKeys above) with no SHELL, so internal/shellenv's LoginShell() falls
// through to shelling out to getent/dscl using that same minimal PATH - which
// on a non-FHS distro like NixOS can't even find getent, and the whole probe
// degrades to WellKnownBinDirs. The process rendering the service definition
// (`daemon install`/`daemon start`, run interactively or from the user's own
// shell) has a normal, complete environment, so resolving SHELL here and
// exporting it fixes the chicken-and-egg problem at its root: the daemon's own
// LoginShell() fast path (a plain env lookup) then succeeds immediately.
var resolveInstallShell = shellenv.LoginShell

// installShellIsDegraded reports whether a resolveInstallShell() result is
// shellenv.LoginShell's last-resort literal "bash" sentinel (or empty)
// rather than a real, resolved shell. This must key off the value itself,
// not path shape: filepath.IsAbs is platform-semantic (a POSIX path like
// /bin/bash is not "absolute" under Windows' drive-letter/UNC rules), so it
// misclassified every valid Unix shell path as degraded on the Windows CI
// leg, which never actually renders these Unix-only service definitions but
// does compile and run this package's tests.
//
// Drift detection (reinstallManagedServiceIfChanged) uses this to avoid
// overwriting an already-installed, previously-resolved SHELL with a
// degraded one: without this check, a later `daemon start` that happens to
// run in a restricted environment (no SHELL, no working getent/bash on PATH
// - the exact NixOS chicken-and-egg problem this baked-in SHELL fixes) would
// re-resolve the degraded fallback, treat the currently good SHELL as drift,
// and reinstall+restart the service onto a broken one.
func installShellIsDegraded(shell string) bool {
shell = strings.TrimSpace(shell)
return shell == "" || shell == "bash"
}

type managedServiceState int

type managedServiceLaunch struct {
Expand Down
13 changes: 9 additions & 4 deletions internal/daemon/service_launchd.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@ func installLaunchAgent(p *paths.Paths, exe string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create launch agents directory: %w", err)
}
shell := resolveInstallShell()
// writeServiceFile resolves the proxy environment once and feeds it to the
// renderer, so the plist content and its permission mode stay in sync
// (see serviceProxyEnv / writeServiceFile).
render := func(proxyEnv [][2]string) string {
return renderLaunchAgentWithProxyEnv(exe, p, home, proxyEnv)
return renderLaunchAgentWithProxyEnv(exe, p, home, shell, proxyEnv)
}
if err := writeServiceFile(path, launchAgentProxyEnv, render); err != nil {
return fmt.Errorf("write launch agent: %w", err)
Expand Down Expand Up @@ -186,12 +187,13 @@ func launchdDomainTarget() (string, error) {
// (preferring the on-disk definition when the live environment has none) and
// pass it in.
func renderLaunchAgent(exe string, p *paths.Paths, home string) string {
return renderLaunchAgentWithProxyEnv(exe, p, home, serviceProxyEnv())
return renderLaunchAgentWithProxyEnv(exe, p, home, resolveInstallShell(), serviceProxyEnv())
}

// renderLaunchAgentWithProxyEnv renders the launchd plist using a proxy
// environment supplied by the caller (see serviceProxyEnv).
func renderLaunchAgentWithProxyEnv(exe string, p *paths.Paths, home string, proxyEnv [][2]string) string {
// environment supplied by the caller (see serviceProxyEnv) and a login shell
// resolved by the caller (see resolveInstallShell).
func renderLaunchAgentWithProxyEnv(exe string, p *paths.Paths, home string, shell string, proxyEnv [][2]string) string {
values := []string{exe, "daemon", "run", "--root", p.Root()}
var args strings.Builder
for _, value := range values {
Expand All @@ -214,6 +216,9 @@ func renderLaunchAgentWithProxyEnv(exe string, p *paths.Paths, home string, prox
envDict.WriteString(" <key>PATH</key>\n <string>")
envDict.WriteString(xmlEscaped(managedServicePath(home)))
envDict.WriteString("</string>\n")
envDict.WriteString(" <key>SHELL</key>\n <string>")
envDict.WriteString(xmlEscaped(shell))
envDict.WriteString("</string>\n")
for _, kv := range proxyEnv {
envDict.WriteString(" <key>")
envDict.WriteString(xmlEscaped(kv[0]))
Expand Down
3 changes: 2 additions & 1 deletion internal/daemon/service_launchd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ func TestStartInstallsLaunchAgentAndBootstrapsManagedDaemon(t *testing.T) {
"<key>EnvironmentVariables</key>",
"<key>HOME</key>",
"<string>" + home + "</string>",
"<key>SHELL</key>\n <string>/bin/bash</string>",
"<key>RunAtLoad</key>",
"<key>KeepAlive</key>",
} {
Expand Down Expand Up @@ -302,7 +303,7 @@ func TestRenderLaunchAgentForwardsEveryProxyEnvKey(t *testing.T) {
proxyEnv = append(proxyEnv, [2]string{key, "val-" + key})
}

plist := renderLaunchAgentWithProxyEnv("/opt/no-mistakes/bin/no-mistakes", paths.WithRoot(t.TempDir()), "/home/u", proxyEnv)
plist := renderLaunchAgentWithProxyEnv("/opt/no-mistakes/bin/no-mistakes", paths.WithRoot(t.TempDir()), "/home/u", "/bin/bash", proxyEnv)
for _, key := range proxyEnvKeys {
fragment := "<key>" + key + "</key>\n <string>val-" + key + "</string>"
if !strings.Contains(plist, fragment) {
Expand Down
85 changes: 85 additions & 0 deletions internal/daemon/service_render.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,35 @@ func systemdUnitProxyEnv(data []byte) [][2]string {
return out
}

// systemdUnitShell extracts the baked-in SHELL Environment= entry from a
// rendered systemd unit, mirroring systemdUnitProxyEnv. Drift detection uses
// this to preserve an already-installed SHELL when the current render
// resolution has degraded (see resolveInstallShell / shellenv.LoginShell's
// literal "bash" fallback) instead of overwriting a previously-resolved,
// working absolute path with an unresolvable one - which would falsely
// detect drift on every subsequent `daemon start` in a restricted
// environment and reinstall+restart the daemon back into the NixOS PATH bug
// this mechanism exists to fix.
func systemdUnitShell(data []byte) (string, bool) {
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "Environment=") {
continue
}
assignment, err := strconv.Unquote(strings.TrimPrefix(line, "Environment="))
if err != nil {
continue
}
assignment = strings.ReplaceAll(assignment, "%%", "%")
key, value, ok := strings.Cut(assignment, "=")
if !ok || key != "SHELL" {
continue
}
return value, true
}
return "", false
}

// launchAgentProxyEnv extracts the forwarded proxy entries from the
// EnvironmentVariables <dict> of a rendered launchd plist, mirroring
// launchAgentExecutable. Pairs are returned in file order with XML escaping
Expand Down Expand Up @@ -210,6 +239,62 @@ func launchAgentProxyEnv(data []byte) [][2]string {
}
}

// launchAgentShell extracts the baked-in SHELL value from the
// EnvironmentVariables <dict> of a rendered launchd plist, mirroring
// launchAgentProxyEnv. See systemdUnitShell for why drift detection needs
// this.
func launchAgentShell(data []byte) (string, bool) {
decoder := xml.NewDecoder(bytes.NewReader(data))
var sawEnvVarsKey bool
var inEnvDict bool
var pendingKey string
var havePendingKey bool
for {
token, err := decoder.Token()
if err != nil {
return "", false
}
switch t := token.(type) {
case xml.StartElement:
switch t.Name.Local {
case "key":
var key string
if err := decoder.DecodeElement(&key, &t); err != nil {
return "", false
}
key = strings.TrimSpace(key)
if inEnvDict {
pendingKey = key
havePendingKey = true
} else {
sawEnvVarsKey = key == "EnvironmentVariables"
}
case "dict":
if sawEnvVarsKey {
inEnvDict = true
sawEnvVarsKey = false
}
case "string":
if !inEnvDict || !havePendingKey {
continue
}
var value string
if err := decoder.DecodeElement(&value, &t); err != nil {
return "", false
}
if pendingKey == "SHELL" {
return value, true
}
havePendingKey = false
}
case xml.EndElement:
if inEnvDict && t.Name.Local == "dict" {
return "", false
}
}
}
}

func firstCommandArg(command string) (string, bool) {
if command == "" {
return "", false
Expand Down
12 changes: 6 additions & 6 deletions internal/daemon/service_render_proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ func TestSystemdUnitProxyEnvRoundTripsThroughRenderer(t *testing.T) {
{"http_proxy", "http://lower:1/"},
}

unit := renderSystemdUnitWithProxyEnv("/usr/local/bin/no-mistakes", p, "/home/u", in)
unit := renderSystemdUnitWithProxyEnv("/usr/local/bin/no-mistakes", p, "/home/u", "/bin/bash", in)

got := systemdUnitProxyEnv([]byte(unit))
if !reflect.DeepEqual(got, in) {
t.Fatalf("systemdUnitProxyEnv() = %v, want %v (HOME/PATH must be skipped, %% undone)", got, in)
}
if reRendered := renderSystemdUnitWithProxyEnv("/usr/local/bin/no-mistakes", p, "/home/u", got); reRendered != unit {
if reRendered := renderSystemdUnitWithProxyEnv("/usr/local/bin/no-mistakes", p, "/home/u", "/bin/bash", got); reRendered != unit {
t.Fatalf("re-rendering parsed proxy env did not reproduce the unit:\nwant:\n%s\ngot:\n%s", unit, reRendered)
}
}
Expand All @@ -43,13 +43,13 @@ func TestLaunchAgentProxyEnvRoundTripsThroughRenderer(t *testing.T) {
{"http_proxy", "http://lower:1/"},
}

plist := renderLaunchAgentWithProxyEnv("/usr/local/bin/no-mistakes", p, "/home/u", in)
plist := renderLaunchAgentWithProxyEnv("/usr/local/bin/no-mistakes", p, "/home/u", "/bin/bash", in)

got := launchAgentProxyEnv([]byte(plist))
if !reflect.DeepEqual(got, in) {
t.Fatalf("launchAgentProxyEnv() = %v, want %v (HOME/PATH must be skipped)", got, in)
}
if reRendered := renderLaunchAgentWithProxyEnv("/usr/local/bin/no-mistakes", p, "/home/u", got); reRendered != plist {
if reRendered := renderLaunchAgentWithProxyEnv("/usr/local/bin/no-mistakes", p, "/home/u", "/bin/bash", got); reRendered != plist {
t.Fatalf("re-rendering parsed proxy env did not reproduce the plist:\nwant:\n%s\ngot:\n%s", plist, reRendered)
}
}
Expand All @@ -59,11 +59,11 @@ func TestLaunchAgentProxyEnvRoundTripsThroughRenderer(t *testing.T) {
// a no-op when there was never a baked-in proxy.
func TestServiceProxyEnvParsersReturnNilForNoProxyDefinition(t *testing.T) {
p := paths.WithRoot(t.TempDir())
unit := renderSystemdUnitWithProxyEnv("/usr/local/bin/no-mistakes", p, "/home/u", nil)
unit := renderSystemdUnitWithProxyEnv("/usr/local/bin/no-mistakes", p, "/home/u", "/bin/bash", nil)
if got := systemdUnitProxyEnv([]byte(unit)); len(got) != 0 {
t.Fatalf("systemdUnitProxyEnv(no-proxy unit) = %v, want empty", got)
}
plist := renderLaunchAgentWithProxyEnv("/usr/local/bin/no-mistakes", p, "/home/u", nil)
plist := renderLaunchAgentWithProxyEnv("/usr/local/bin/no-mistakes", p, "/home/u", "/bin/bash", nil)
if got := launchAgentProxyEnv([]byte(plist)); len(got) != 0 {
t.Fatalf("launchAgentProxyEnv(no-proxy plist) = %v, want empty", got)
}
Expand Down
Loading
Loading