From 9712c64348272119b75d044abcb2e87c0a457a19 Mon Sep 17 00:00:00 2001 From: Chris Stephens Date: Sat, 22 Aug 2026 23:31:50 -0400 Subject: [PATCH] fix: decouple launchd from package paths --- CHANGELOG.md | 14 ++++++- README.md | 11 +++--- VERSION | 2 +- docs/privacy.md | 5 ++- lib/ai_optimizer/scheduler.rb | 37 +++++++++++++------ test/scheduler_test.rb | 69 +++++++++++++++++++++++++++++++---- 6 files changed, 110 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c2a30f..f0578e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,24 @@ All notable changes are documented here. AI Optimizer follows semantic versioning. +## 0.1.6 - 2026-08-22 + +- Keep the configured AI Optimizer executable out of the launchd plist + entirely; a versioned product-owned launcher embeds its absolute path with + shell-safe quoting. +- Make launcher identity stable across Homebrew keg target changes and refuse + drifted or symlinked launcher files. +- Add live-upgrade regression coverage using a Homebrew revision change that + removes the prior Cellar directory. + ## 0.1.5 - 2026-08-22 - Replace the short-lived `/usr/bin/env` launch item with a stable, product-owned maintenance launcher under Application Support. - Keep the exact AI Optimizer executable out of launchd's program arguments so - Homebrew keg replacement does not unregister an opted-in schedule. + it is not the registered launch program. This was superseded by v0.1.6 after + live evidence showed an executable path in plist environment state was still + associated with keg replacement. - Refuse symlinked launcher targets and install the launcher atomically with owner-only permissions. diff --git a/README.md b/README.md index c331293..39d90fa 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ skills, repositories, or launchd unless you explicitly add `--schedule`. Example: ```text -AI Optimizer 0.1.5 +AI Optimizer 0.1.6 [PASS] system.macos - macOS is supported [PASS] tools.claude.present - Claude Code is available @@ -77,9 +77,10 @@ later in the morning records `skipped_outside_window` and performs no scan. Configuration, receipts, and scheduler logs are stored with owner-only permissions. The launch agent uses an owner-only, product-owned maintenance launcher under -Application Support. The launcher reads AI Optimizer's absolute executable -path from its launchd environment, keeping package upgrades from silently -unregistering an already opted-in schedule. +Application Support. Its versioned filename is stable for the configured +executable path, and the Homebrew or direct-install path does not appear in the +launchd plist. Package upgrades therefore leave an already opted-in schedule +registered. AI Optimizer owns only: @@ -120,7 +121,7 @@ The direct path verifies the installer before it runs, then the installer verifies the release archive before changing live paths: ```sh -VERSION=0.1.5 +VERSION=0.1.6 curl -fLO "https://github.com/nyldn/ai-optimizer/releases/download/v$VERSION/install.sh" curl -fLO "https://github.com/nyldn/ai-optimizer/releases/download/v$VERSION/install.sh.sha256" shasum -a 256 -c install.sh.sha256 diff --git a/VERSION b/VERSION index 9faa1b7..c946ee6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.5 +0.1.6 diff --git a/docs/privacy.md b/docs/privacy.md index eb08243..900df2f 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -1,6 +1,6 @@ # Privacy -AI Optimizer 0.1.5 has no telemetry and sends no diagnostic data anywhere. +AI Optimizer 0.1.6 has no telemetry and sends no diagnostic data anywhere. ## Read @@ -32,7 +32,8 @@ write or remove only AI Optimizer-owned files and the documented launchd label. Scheduled maintenance writes one local run receipt after the execution-time evening guard. Configuration, receipts, and scheduler logs use owner-only permissions. Its fixed maintenance launcher is also owner-only and uses only -the exact executable path written by the explicit `schedule` command. +the shell-quoted absolute executable path written by the explicit `schedule` +command. That path does not appear in the launchd plist. AI Optimizer refuses to claim a nonempty Application Support directory unless its state manifest already proves product ownership. diff --git a/lib/ai_optimizer/scheduler.rb b/lib/ai_optimizer/scheduler.rb index 264df92..ae4d0df 100644 --- a/lib/ai_optimizer/scheduler.rb +++ b/lib/ai_optimizer/scheduler.rb @@ -1,17 +1,14 @@ # frozen_string_literal: true require "cgi" +require "digest" require "fileutils" module AIOptimizer class Scheduler LABEL = "io.github.nyldn.ai-optimizer.daily" LOG_FILES = %w[daily.out.log daily.err.log].freeze - WRAPPER_NAME = "ai-optimizer-maintenance" - WRAPPER_CONTENT = <<~'SH'.freeze - #!/bin/sh - exec "$AI_OPTIMIZER_EXECUTABLE" run-maintenance - SH + WRAPPER_NAME = "ai-optimizer-maintenance-v1" attr_reader :launch_agents_dir, :data_dir, :executable, :uid, :runner @@ -104,8 +101,10 @@ def ensure_owned_paths return unless File.file?(plist_path) content = File.binread(plist_path, 64_000) + versioned_wrapper = %r{/#{Regexp.escape(WRAPPER_NAME)}-[0-9a-f]{12}} command_owned = content.include?("run-maintenance") || - content.include?("/#{WRAPPER_NAME}") + content.include?("/ai-optimizer-maintenance") || + content.match?(versioned_wrapper) owned = content.include?("#{LABEL}") && command_owned raise OwnershipError, "existing launch agent is not provably owned by AI Optimizer" unless owned end @@ -143,7 +142,16 @@ def secure_log_files end def maintenance_wrapper_path - File.join(data_dir, "bin", WRAPPER_NAME) + digest = Digest::SHA256.hexdigest(executable)[0, 12] + File.join(data_dir, "bin", "#{WRAPPER_NAME}-#{digest}") + end + + def maintenance_wrapper_content + "#!/bin/sh\nexec #{shell_quote(executable)} run-maintenance\n" + end + + def shell_quote(value) + "'#{value.gsub("'", %q('"'"'))}'" end def write_maintenance_wrapper @@ -153,9 +161,18 @@ def write_maintenance_wrapper FileUtils.mkdir_p(bin_dir, mode: 0o700) File.chmod(0o700, bin_dir) - temporary = File.join(bin_dir, ".#{WRAPPER_NAME}.#{Process.pid}.tmp") + if File.exist?(maintenance_wrapper_path) + existing = File.file?(maintenance_wrapper_path) && File.binread(maintenance_wrapper_path) + expected = maintenance_wrapper_content.dup.force_encoding(Encoding::BINARY) + raise OwnershipError, "existing maintenance launcher does not match AI Optimizer" unless existing == expected + + File.chmod(0o700, maintenance_wrapper_path) + return + end + + temporary = File.join(bin_dir, ".#{File.basename(maintenance_wrapper_path)}.#{Process.pid}.tmp") File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o700) do |file| - file.write(WRAPPER_CONTENT) + file.write(maintenance_wrapper_content) file.flush file.fsync end @@ -205,8 +222,6 @@ def plist(hour, minute) /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin AI_OPTIMIZER_DATA_DIR #{escape(data_dir)} - AI_OPTIMIZER_EXECUTABLE - #{escape(executable)} StandardOutPath #{escape(File.join(log_dir, "daily.out.log"))} diff --git a/test/scheduler_test.rb b/test/scheduler_test.rb index 35420b9..e199c3e 100644 --- a/test/scheduler_test.rb +++ b/test/scheduler_test.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "digest" require_relative "test_helper" class SchedulerTest < Minitest::Test @@ -25,16 +26,15 @@ def test_default_evening_schedule_round_trip_is_exact_and_idempotent assert_includes plist, "io.github.nyldn.ai-optimizer.daily" assert_includes plist, "21" refute_includes plist, "com.chris" - wrapper_path = File.join(dir, "data", "bin", "ai-optimizer-maintenance") + wrapper_digest = Digest::SHA256.hexdigest("/usr/local/bin/ai-optimizer")[0, 12] + wrapper_path = File.join(dir, "data", "bin", "ai-optimizer-maintenance-v1-#{wrapper_digest}") expected_program = %r{ProgramArguments\s*\s*#{Regexp.escape(wrapper_path)}\s*} assert_match expected_program, plist - assert_match %r{AI_OPTIMIZER_EXECUTABLE\s*/usr/local/bin/ai-optimizer}, plist - assert_equal "#!/bin/sh\nexec \"$AI_OPTIMIZER_EXECUTABLE\" run-maintenance\n", File.read(wrapper_path) + refute_includes plist, "AI_OPTIMIZER_EXECUTABLE" + refute_includes plist, "/usr/local/bin/ai-optimizer" + assert_equal "#!/bin/sh\nexec '/usr/local/bin/ai-optimizer' run-maintenance\n", File.read(wrapper_path) assert_equal 0o700, File.stat(File.dirname(wrapper_path)).mode & 0o777 assert_equal 0o700, File.stat(wrapper_path).mode & 0o777 - stdout, stderr, status = Open3.capture3({ "AI_OPTIMIZER_EXECUTABLE" => "/usr/bin/printf" }, wrapper_path) - assert status.success?, stderr - assert_equal "run-maintenance", stdout scheduler.schedule(hour: 21, minute: 0) assert File.file?(scheduler.plist_path) %w[daily.out.log daily.err.log].each do |name| @@ -101,10 +101,13 @@ def test_schedule_refuses_a_symlinked_maintenance_launcher FileUtils.mkdir_p(bin_dir) external = File.join(dir, "external-launcher") File.write(external, "must remain unchanged\n") - File.symlink(external, File.join(bin_dir, "ai-optimizer-maintenance")) + executable = "/usr/local/bin/ai-optimizer" + wrapper_digest = Digest::SHA256.hexdigest(executable)[0, 12] + wrapper_path = File.join(bin_dir, "ai-optimizer-maintenance-v1-#{wrapper_digest}") + File.symlink(external, wrapper_path) scheduler = AIOptimizer::Scheduler.new( launch_agents_dir: File.join(dir, "agents"), data_dir: data_dir, - executable: "/usr/local/bin/ai-optimizer", uid: 501, + executable: executable, uid: 501, runner: TestSupport::FakeCommandRunner.new ) @@ -113,6 +116,56 @@ def test_schedule_refuses_a_symlinked_maintenance_launcher end end + def test_maintenance_launcher_executes_the_exact_configured_path + in_tmpdir do |dir| + agents = File.join(dir, "agents") + executable_dir = File.join(dir, "tool's bin-José") + FileUtils.mkdir_p(executable_dir) + executable = File.join(executable_dir, "ai optimizer") + File.write(executable, "#!/bin/sh\nprintf '%s' \"$1\"\n") + File.chmod(0o700, executable) + plist_path = File.join(agents, "io.github.nyldn.ai-optimizer.daily.plist") + runner = TestSupport::FakeCommandRunner.new( + "/bin/launchctl print gui/501/io.github.nyldn.ai-optimizer.daily" => { status: 1, stdout: "", stderr: "not found" }, + "/usr/bin/plutil -lint #{File.join(agents, ".io.github.nyldn.ai-optimizer.daily.plist.tmp")}" => { status: 0, stdout: "OK", stderr: "" }, + "/bin/launchctl bootstrap gui/501 #{plist_path}" => { status: 0, stdout: "", stderr: "" } + ) + scheduler = AIOptimizer::Scheduler.new( + launch_agents_dir: agents, data_dir: File.join(dir, "data"), + executable: executable, uid: 501, runner: runner + ) + + scheduler.schedule(hour: 21, minute: 0) + scheduler.schedule(hour: 21, minute: 0) + + wrapper_digest = Digest::SHA256.hexdigest(executable)[0, 12] + wrapper_path = File.join(dir, "data", "bin", "ai-optimizer-maintenance-v1-#{wrapper_digest}") + stdout, stderr, status = Open3.capture3(wrapper_path) + assert status.success?, stderr + assert_equal "run-maintenance", stdout + end + end + + def test_schedule_refuses_a_drifted_maintenance_launcher + in_tmpdir do |dir| + data_dir = File.join(dir, "data") + bin_dir = File.join(data_dir, "bin") + FileUtils.mkdir_p(bin_dir) + executable = "/usr/local/bin/ai-optimizer" + wrapper_digest = Digest::SHA256.hexdigest(executable)[0, 12] + wrapper_path = File.join(bin_dir, "ai-optimizer-maintenance-v1-#{wrapper_digest}") + File.write(wrapper_path, "unowned content\n") + scheduler = AIOptimizer::Scheduler.new( + launch_agents_dir: File.join(dir, "agents"), data_dir: data_dir, + executable: executable, uid: 501, + runner: TestSupport::FakeCommandRunner.new + ) + + assert_raises(AIOptimizer::OwnershipError) { scheduler.schedule(hour: 21, minute: 0) } + assert_equal "unowned content\n", File.read(wrapper_path) + end + end + def test_rejects_outside_window_without_explicit_override in_tmpdir do |dir| scheduler = AIOptimizer::Scheduler.new(