Skip to content
Merged
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
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.5
0.1.6
5 changes: 3 additions & 2 deletions docs/privacy.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.
37 changes: 26 additions & 11 deletions lib/ai_optimizer/scheduler.rb
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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}</string>}
command_owned = content.include?("<string>run-maintenance</string>") ||
content.include?("/#{WRAPPER_NAME}</string>")
content.include?("/ai-optimizer-maintenance</string>") ||
content.match?(versioned_wrapper)
owned = content.include?("<string>#{LABEL}</string>") && command_owned
raise OwnershipError, "existing launch agent is not provably owned by AI Optimizer" unless owned
end
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -205,8 +222,6 @@ def plist(hour, minute)
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>AI_OPTIMIZER_DATA_DIR</key>
<string>#{escape(data_dir)}</string>
<key>AI_OPTIMIZER_EXECUTABLE</key>
<string>#{escape(executable)}</string>
</dict>
<key>StandardOutPath</key>
<string>#{escape(File.join(log_dir, "daily.out.log"))}</string>
Expand Down
69 changes: 61 additions & 8 deletions test/scheduler_test.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# frozen_string_literal: true

require "digest"
require_relative "test_helper"

class SchedulerTest < Minitest::Test
Expand All @@ -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, "<integer>21</integer>"
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{<key>ProgramArguments</key>\s*<array>\s*<string>#{Regexp.escape(wrapper_path)}</string>\s*</array>}
assert_match expected_program, plist
assert_match %r{<key>AI_OPTIMIZER_EXECUTABLE</key>\s*<string>/usr/local/bin/ai-optimizer</string>}, 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|
Expand Down Expand Up @@ -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
)

Expand All @@ -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(
Expand Down
Loading