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
15 changes: 12 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,20 @@
All notable changes are documented here. AI Optimizer follows semantic
versioning.

## 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.
- Refuse symlinked launcher targets and install the launcher atomically with
owner-only permissions.

## 0.1.4 - 2026-08-22

- Keep opted-in launchd jobs registered across Homebrew binary replacement by
using immutable `/usr/bin/env` as the launch program and passing the exact AI
Optimizer executable as an argument.
- Use immutable `/usr/bin/env` as the launch program. This was superseded by
v0.1.5 after live Background Task Management evidence showed macOS removed
the generic `env` launch item.

## 0.1.3 - 2026-08-22

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.4
AI Optimizer 0.1.5

[PASS] system.macos - macOS is supported
[PASS] tools.claude.present - Claude Code is available
Expand Down Expand Up @@ -76,9 +76,10 @@ checks the time again when launchd actually starts the process. A Mac waking
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 the system `/usr/bin/env` wrapper with AI Optimizer's
absolute executable path so package upgrades do not silently unregister an
already opted-in schedule.
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.

AI Optimizer owns only:

Expand Down Expand Up @@ -119,7 +120,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.4
VERSION=0.1.5
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.4
0.1.5
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.4 has no telemetry and sends no diagnostic data anywhere.
AI Optimizer 0.1.5 has no telemetry and sends no diagnostic data anywhere.

## Read

Expand Down Expand Up @@ -31,7 +31,8 @@ finding.
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.
permissions. Its fixed maintenance launcher is also owner-only and uses only
the exact executable path written by the explicit `schedule` command.

AI Optimizer refuses to claim a nonempty Application Support directory unless
its state manifest already proves product ownership.
39 changes: 35 additions & 4 deletions lib/ai_optimizer/scheduler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ 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

attr_reader :launch_agents_dir, :data_dir, :executable, :uid, :runner

Expand All @@ -29,6 +34,7 @@ def schedule(hour: 21, minute: 0, force_outside_window: false)
ensure_owned_paths
FileUtils.mkdir_p(launch_agents_dir, mode: 0o700)
secure_log_files
write_maintenance_wrapper

previous_content = File.file?(plist_path) ? File.binread(plist_path) : nil

Expand Down Expand Up @@ -98,7 +104,9 @@ def ensure_owned_paths
return unless File.file?(plist_path)

content = File.binread(plist_path, 64_000)
owned = content.include?("<string>#{LABEL}</string>") && content.include?("<string>run-maintenance</string>")
command_owned = content.include?("<string>run-maintenance</string>") ||
content.include?("/#{WRAPPER_NAME}</string>")
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 @@ -134,6 +142,29 @@ def secure_log_files
raise OwnershipError, "refusing to use a symlinked log file"
end

def maintenance_wrapper_path
File.join(data_dir, "bin", WRAPPER_NAME)
end

def write_maintenance_wrapper
bin_dir = File.dirname(maintenance_wrapper_path)
raise OwnershipError, "maintenance bin directory must not be a symlink" if File.symlink?(bin_dir)
raise OwnershipError, "refusing to replace a symlinked maintenance launcher" if File.symlink?(maintenance_wrapper_path)

FileUtils.mkdir_p(bin_dir, mode: 0o700)
File.chmod(0o700, bin_dir)
temporary = File.join(bin_dir, ".#{WRAPPER_NAME}.#{Process.pid}.tmp")
File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o700) do |file|
file.write(WRAPPER_CONTENT)
file.flush
file.fsync
end
File.chmod(0o700, temporary)
File.rename(temporary, maintenance_wrapper_path)
ensure
FileUtils.rm_f(temporary) if defined?(temporary) && temporary && File.exist?(temporary)
end

def loaded?
runner.run(["/bin/launchctl", "print", service_target], timeout: 5).success?
end
Expand All @@ -157,9 +188,7 @@ def plist(hour, minute)
<string>#{LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/env</string>
<string>#{escape(executable)}</string>
<string>run-maintenance</string>
<string>#{escape(maintenance_wrapper_path)}</string>
</array>
<key>StartCalendarInterval</key>
<dict>
Expand All @@ -176,6 +205,8 @@ 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
36 changes: 30 additions & 6 deletions test/scheduler_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,18 @@ 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"
expected_program = %r{
<key>ProgramArguments</key>\s*<array>\s*
<string>/usr/bin/env</string>\s*
<string>/usr/local/bin/ai-optimizer</string>\s*
<string>run-maintenance</string>
}x
wrapper_path = File.join(dir, "data", "bin", "ai-optimizer-maintenance")
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)
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|
log_path = File.join(dir, "data", "logs", name)
assert File.file?(log_path)
Expand Down Expand Up @@ -89,6 +94,25 @@ def test_schedule_refuses_a_symlinked_log_file
end
end

def test_schedule_refuses_a_symlinked_maintenance_launcher
in_tmpdir do |dir|
data_dir = File.join(dir, "data")
bin_dir = File.join(data_dir, "bin")
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"))
scheduler = AIOptimizer::Scheduler.new(
launch_agents_dir: File.join(dir, "agents"), data_dir: data_dir,
executable: "/usr/local/bin/ai-optimizer", uid: 501,
runner: TestSupport::FakeCommandRunner.new
)

assert_raises(AIOptimizer::OwnershipError) { scheduler.schedule(hour: 21, minute: 0) }
assert_equal "must remain unchanged\n", File.read(external)
end
end

def test_rejects_outside_window_without_explicit_override
in_tmpdir do |dir|
scheduler = AIOptimizer::Scheduler.new(
Expand Down
Loading