Skip to content

feat(tray): enhanced menu#625

Closed
wolf31o2 wants to merge 2 commits intomainfrom
feature/tray-enhanced-menu
Closed

feat(tray): enhanced menu#625
wolf31o2 wants to merge 2 commits intomainfrom
feature/tray-enhanced-menu

Conversation

@wolf31o2
Copy link
Copy Markdown
Member

@wolf31o2 wolf31o2 commented Feb 18, 2026

Summary by cubic

Enhances the tray with a live status display and new menu actions to manage Adder. Adds health checks, event streaming, and auto-restart to make the app more resilient.

  • New Features

    • Live status tracking with a StatusTracker and periodic health polling from the Adder API (/healthcheck).
    • ProcessManager now parses newline-delimited JSON events from stdout and auto-restarts on crash with exponential backoff.
    • New tray items: Status (read-only), Start/Stop/Restart, Show Config Folder, Show Logs, and About.
    • Adder config generator and writer with templates for Watch Wallet, Track DRep, and Monitor Pool (YAML output).
    • Cross-platform service helpers: systemd user unit (Linux), launchd agent (macOS), and Windows Task Scheduler, with status checks and register/unregister.
  • Bug Fixes

    • Hardened network calls: validate Kupo URL scheme/host, construct FCM URL safely, and add #nosec notes for user-provided endpoints.

Written for commit c648701. Summary will update on new commits.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added tray app status display with real-time service state monitoring
    • New menu actions: Show Config Folder, Show Logs, and About Adder
    • Automatic process restart with exponential backoff on failure
    • Health polling for service connectivity detection
    • Cross-platform service registration (macOS, Linux, Windows)
    • Event streaming from service for real-time updates
  • Bug Fixes

    • Enhanced URL validation for external endpoints
  • Tests

    • Comprehensive test coverage for configuration, health monitoring, status tracking, and service management

@wolf31o2 wolf31o2 requested a review from a team as a code owner February 18, 2026 22:52
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 18, 2026

📝 Walkthrough

Walkthrough

This PR enhances URL validation and security practices across multiple modules while introducing comprehensive tray application infrastructure. Input modules now validate URL schemes and hosts before creating clients, with hardcoded endpoints replaced by proper URL construction. Security lint suppressions are added to HTTP request operations. The tray package receives significant new functionality: configuration generation for adder via YAML, status tracking with callbacks, health polling via HTTP endpoints, event parsing from JSON streams, process auto-restart with exponential backoff, and platform-specific service registration for macOS (LaunchAgent), Linux (systemd), and Windows (scheduled tasks).

Possibly related PRs

  • blinklabs-io/adder#608: Adds and modifies the same tray package files, indicating overlapping implementation of tray application enhancements.
🚥 Pre-merge checks | ✅ 1 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The title 'feat(tray): enhanced menu' is vague and does not clearly convey the main scope of changes. While the PR does enhance the tray menu, the changeset includes substantially more: status tracking, health polling, process event parsing, auto-restart functionality, config generation, and cross-platform service management. The title focuses only on menu enhancements and omits the primary infrastructure added. Consider a more comprehensive title like 'feat(tray): add status tracking, health polling, event parsing, and service management' to better reflect the substantial changes beyond just menu enhancements.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/tray-enhanced-menu

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 13 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="tray/health.go">

<violation number="1" location="tray/health.go:110">
P2: Handle non-200 HTTP responses as failures; otherwise a server error that still returns JSON can be treated as healthy and reset the failure count.</violation>
</file>

<file name="tray/service_windows.go">

<violation number="1" location="tray/service_windows.go:72">
P2: `serviceStatusCheck` suppresses all query errors and reports `ServiceNotRegistered`, which can hide real failures (e.g., permissions or command errors). Return the error (or at least surface it) so callers can distinguish “not registered” from a failed query.</violation>
</file>

<file name="tray/service_linux.go">

<violation number="1" location="tray/service_linux.go:38">
P2: Quote the binary and config paths in the systemd unit template so paths containing spaces/special characters are passed correctly to systemd.</violation>
</file>

<file name="tray/events.go">

<violation number="1" location="tray/events.go:57">
P2: Stop() doesn’t actually stop the parser while Scan is blocked, so the goroutine can hang indefinitely if the reader stays open. Consider storing an io.ReadCloser and closing it in Stop (or adding a context-aware reader) so Stop reliably unblocks Scan.</violation>
</file>

<file name="tray/service_darwin.go">

<violation number="1" location="tray/service_darwin.go:40">
P2: Escape path values when rendering the plist. Using raw `text/template` inserts `BinaryPath`, `ConfigPath`, and `LogDir` without XML escaping, so paths containing `&` or `<` will break the plist or allow injection.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

}
return
}
defer resp.Body.Close()
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Handle non-200 HTTP responses as failures; otherwise a server error that still returns JSON can be treated as healthy and reset the failure count.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tray/health.go, line 110:

<comment>Handle non-200 HTTP responses as failures; otherwise a server error that still returns JSON can be treated as healthy and reset the failure count.</comment>

<file context>
@@ -0,0 +1,147 @@
+		}
+		return
+	}
+	defer resp.Body.Close()
+
+	var hr HealthResponse
</file context>
Fix with Cubic

"/NH",
).CombinedOutput()
if err != nil {
return ServiceNotRegistered, nil
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: serviceStatusCheck suppresses all query errors and reports ServiceNotRegistered, which can hide real failures (e.g., permissions or command errors). Return the error (or at least surface it) so callers can distinguish “not registered” from a failed query.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tray/service_windows.go, line 72:

<comment>`serviceStatusCheck` suppresses all query errors and reports `ServiceNotRegistered`, which can hide real failures (e.g., permissions or command errors). Return the error (or at least surface it) so callers can distinguish “not registered” from a failed query.</comment>

<file context>
@@ -0,0 +1,80 @@
+		"/NH",
+	).CombinedOutput()
+	if err != nil {
+		return ServiceNotRegistered, nil
+	}
+
</file context>
Fix with Cubic


[Service]
Type=simple
ExecStart={{.BinaryPath}}{{if .ConfigPath}} --config {{.ConfigPath}}{{end}}
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Quote the binary and config paths in the systemd unit template so paths containing spaces/special characters are passed correctly to systemd.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tray/service_linux.go, line 38:

<comment>Quote the binary and config paths in the systemd unit template so paths containing spaces/special characters are passed correctly to systemd.</comment>

<file context>
@@ -0,0 +1,128 @@
+
+[Service]
+Type=simple
+ExecStart={{.BinaryPath}}{{if .ConfigPath}} --config {{.ConfigPath}}{{end}}
+Restart=on-failure
+RestartSec=5
</file context>
Fix with Cubic


// Stop signals the event parser to stop. Note that the parser will
// also stop naturally when the underlying reader is closed.
func (ep *EventParser) Stop() {
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Stop() doesn’t actually stop the parser while Scan is blocked, so the goroutine can hang indefinitely if the reader stays open. Consider storing an io.ReadCloser and closing it in Stop (or adding a context-aware reader) so Stop reliably unblocks Scan.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tray/events.go, line 57:

<comment>Stop() doesn’t actually stop the parser while Scan is blocked, so the goroutine can hang indefinitely if the reader stays open. Consider storing an io.ReadCloser and closing it in Stop (or adding a context-aware reader) so Stop reliably unblocks Scan.</comment>

<file context>
@@ -0,0 +1,104 @@
+
+// Stop signals the event parser to stop. Note that the parser will
+// also stop naturally when the underlying reader is closed.
+func (ep *EventParser) Stop() {
+	select {
+	case <-ep.done:
</file context>
Fix with Cubic

<string>io.blinklabs.adder</string>
<key>ProgramArguments</key>
<array>
<string>{{.BinaryPath}}</string>
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Escape path values when rendering the plist. Using raw text/template inserts BinaryPath, ConfigPath, and LogDir without XML escaping, so paths containing & or < will break the plist or allow injection.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tray/service_darwin.go, line 40:

<comment>Escape path values when rendering the plist. Using raw `text/template` inserts `BinaryPath`, `ConfigPath`, and `LogDir` without XML escaping, so paths containing `&` or `<` will break the plist or allow injection.</comment>

<file context>
@@ -0,0 +1,137 @@
+    <string>io.blinklabs.adder</string>
+    <key>ProgramArguments</key>
+    <array>
+        <string>{{.BinaryPath}}</string>
+        {{- if .ConfigPath}}
+        <string>--config</string>
</file context>
Fix with Cubic

Signed-off-by: Chris Gianelloni <wolf31o2@blinklabs.io>
Signed-off-by: Chris Gianelloni <wolf31o2@blinklabs.io>
@wolf31o2 wolf31o2 force-pushed the feature/tray-enhanced-menu branch from 5dfb069 to c648701 Compare February 22, 2026 22:35
@wolf31o2
Copy link
Copy Markdown
Member Author

@cubic-dev-ai review

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai bot commented Feb 22, 2026

@cubic-dev-ai review

@wolf31o2 I have started the AI code review. It will take a few minutes to complete.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
output/webhook/webhook.go (1)

343-347: ⚠️ Potential issue | 🟡 Minor

Pre-existing: response body leak when io.ReadAll fails.

defer resp.Body.Close() is registered at line 347, after the early return at line 345. If io.ReadAll returns an error, the body is never closed. Move the defer immediately after the resp == nil guard (after line 342) so it fires on every return path.

🐛 Proposed fix
 	if resp == nil {
 		return fmt.Errorf("failed to send payload: %s", data)
 	}
+	defer resp.Body.Close()
 	respBody, err := io.ReadAll(resp.Body)
 	if err != nil {
 		return fmt.Errorf("%w", err)
 	}
-	defer resp.Body.Close()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@output/webhook/webhook.go` around lines 343 - 347, The response body can leak
if io.ReadAll(resp.Body) returns an error because defer resp.Body.Close() is
registered after that call; move the defer immediately after the nil-check for
resp (i.e., right after the resp == nil guard) so resp.Body.Close() is
guaranteed on every return path, keeping the existing io.ReadAll usage and error
handling intact; update the function that performs the HTTP call where resp,
resp.Body and io.ReadAll are used to register defer resp.Body.Close() before
attempting to read the body.
🧹 Nitpick comments (4)
tray/health.go (1)

82-94: First health poll won't occur until 10 seconds after Start().

The ticker fires after the first interval elapses, so the status will remain in its previous state (likely StatusStarting) for up to 10 seconds before the first check runs. Consider doing an immediate hp.poll() call before entering the ticker loop if faster initial feedback is desired.

♻️ Optional: add an immediate first poll
 func (hp *HealthPoller) run() {
 	ticker := time.NewTicker(healthPollInterval)
 	defer ticker.Stop()
 
+	// Immediate first check
+	hp.poll()
+
 	for {
 		select {
 		case <-hp.stopCh:
 			return
 		case <-ticker.C:
 			hp.poll()
 		}
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tray/health.go` around lines 82 - 94, The initial health check is delayed
because HealthPoller.run only starts the ticker; call hp.poll() once immediately
before entering the for/select loop so the first health poll occurs right away;
specifically, add an immediate hp.poll() invocation at the start of
HealthPoller.run (before creating or before the for loop) so subsequent
ticker-driven polls remain unchanged and hp.stopCh handling via the select stays
the same.
tray/service_linux.go (1)

75-99: registerService enables but does not start the service.

After daemon-reload and enable, the unit is set to start at login but is not started immediately. The Darwin implementation uses RunAtLoad + launchctl load which starts the agent. Consider adding systemctl --user start for parity, or document that the service only starts on next login.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tray/service_linux.go` around lines 75 - 99, The registerService function
currently enables the unit but doesn't start it; after the existing systemctl
--user enable step, invoke systemctl --user start <serviceName> (using
exec.Command and CombinedOutput like the daemon-reload and enable calls) and
propagate a formatted error including the trimmed output on failure so the
service is started immediately (or alternatively add a comment/doc noting it
only starts at next login); locate and update registerService and use the
existing serviceName symbol for the start command.
tray/app.go (1)

144-145: "About" action only logs — not visible to the user.

Clicking "About Adder" calls slog.Info(...), which writes to the application log but shows nothing in the UI. Users would expect a dialog or notification. This appears to be a placeholder implementation.

Would you like me to open an issue to track adding a proper About dialog (e.g., using a notification or a small window)?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tray/app.go` around lines 144 - 145, The "About" menu handler currently only
calls slog.Info inside the mAbout.ClickedCh case which logs but shows nothing to
users; replace that logging call with a UI-visible action by invoking a function
like showAboutDialog or showNotification from the mAbout.ClickedCh branch
(create it if missing) that displays a small modal or system notification
containing the app name, version and brief description; ensure the new function
is called instead of slog.Info in the case block and reuse existing
UI/notification utilities in the codebase if available.
tray/service_darwin.go (1)

104-108: Replace deprecated launchctl load/unload with bootstrap/bootout commands.

launchctl load and unload are legacy subcommands; macOS recommends bootstrap, bootout, enable, and disable instead. Proper migration requires:

  • load -w (line 104): Replace with enable + bootstrap gui/$(id -u) <plist-path> to restore the persistence behavior of -w
  • unload (line 112): Replace with bootout gui/$(id -u) <plist-path> + disable

Since this is a per-user LaunchAgent (in ~/Library/LaunchAgents/), the domain target is gui/$(id -u).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tray/service_darwin.go` around lines 104 - 108, Replace the legacy "launchctl
load -w" invocation in the install/start code (the exec.Command call that uses
servicePlistPath()) with two invocations that (1) run "launchctl enable
<domain>/<label>" and (2) run "launchctl bootstrap <domain> <plistPath>" using
the per-user domain "gui/$(id -u)" — obtain the uid programmatically
(os.Getuid()) to form "gui/<uid>" and execute both commands via
exec.Command/CombinedOutput and preserve the existing error wrapping/logging;
likewise, replace the corresponding "launchctl unload" in the uninstall/stop
path with "launchctl bootout <domain> <plistPath>" followed by "launchctl
disable <domain>/<label>" (or disable the plist path) using the same
servicePlistPath() and uid-derived domain, ensuring you keep CombinedOutput and
the same error formatting (fmt.Errorf) on failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@input/chainsync/chainsync.go`:
- Around line 768-769: Replace the incorrect gosec suppression tag above the
HTTP call: change the comment "// `#nosec` G704 -- Kupo endpoint is
user-configured and validated before use." to use the correct rule ID "// `#nosec`
G107 -- Kupo endpoint is user-configured and validated before use." so the
suppression applies to the tainted URL rule around the httpClient.Do(req) call
(the line where resp, err := httpClient.Do(req) is invoked).

In `@output/push/fcm/message.go`:
- Around line 77-81: The URL path is built by interpolating projectId into
url.URL.Path which leaves it unescaped and the current `#nosec G704` is
incorrect; fix by percent-escaping or using safe join: replace the manual
url.URL construction for fcmURL with a call that escapes projectId (e.g., use
url.PathEscape(projectId) when building the Path or better use
url.JoinPath("https://fcm.googleapis.com", "v1/projects", projectId,
"messages:send") and handle the returned error), and update/remove the `#nosec`
comment so it accurately reflects the mitigation applied; locate and change the
code that constructs fcmURL and the `projectId` usage in message.go.

In `@output/webhook/webhook.go`:
- Around line 335-336: Move the response body close so it cannot leak: after
calling client.Do(req) and before calling io.ReadAll, immediately check err and
then defer resp.Body.Close() (so resp.Body is always closed even if ReadAll
fails); also add URL scheme validation for w.url (the value used to build the
request) similar to chainsync/mempool modules by parsing w.url and rejecting any
non-http/https scheme prior to making client.Do(req) to ensure consistent
defense-in-depth.

In `@tray/app.go`:
- Around line 55-61: The process manager is constructed with
WithAutoRestart(true) via NewProcessManager but Stop() in tray/process.go sets
pm.autoRestart = false permanently, and Start() never restores it; change
Start() (the Start method on the ProcessManager) to reset pm.autoRestart to the
configured default when starting (e.g., reapply the configured autoRestart flag
stored on the manager or re-run the constructor-provided option value) so that a
Stop→Start only suppresses auto-restart for that stop cycle rather than
disabling it for the session; reference NewProcessManager, WithAutoRestart,
Stop(), Start(), and pm.autoRestart to locate the relevant code.

In `@tray/events.go`:
- Around line 51-53: Start() currently launches goroutine unguarded which lets
multiple calls race on the same bufio.Scanner and cause close(ep.events) to run
twice; add a guard (preferably a sync.Once field like startOnce on EventParser)
and change Start() to call startOnce.Do(func(){ go ep.run() }) so run() is only
started once (this avoids double-closing ep.events and races on the scanner);
ensure the new startOnce field is initialized with zero value and reference
EventParser.Start(), EventParser.run(), and ep.events in the change.

In `@tray/health.go`:
- Around line 96-110: In HealthPoller.poll(), when hp.client.Get returns a
non-nil resp and a non-nil err the code currently returns without closing
resp.Body; update the error path to close the response body before returning
(e.g., if resp != nil call resp.Body.Close() or defer it immediately after
verifying resp != nil) so that any open response is always closed when leaving
poll; reference the hp.client.Get call and the resp variable in your change.

In `@tray/process_test.go`:
- Around line 162-174: TestEventParser_EOF starts an EventParser with ep.Start()
but never calls ep.Stop(), risking a goroutine leak; update the test
(TestEventParser_EOF) to call ep.Stop() (preferably via defer ep.Stop()
immediately after ep.Start()) so the EventParser goroutine is always terminated,
mirroring other tests that call ep.Stop() and ensuring clean shutdown on EOF.

In `@tray/process.go`:
- Around line 252-279: The restart goroutine can race with Stop(): after
sleeping it should re-check pm.autoRestart under pm.mu before calling pm.Start
to respect a user Stop; modify the anonymous goroutine started after
backoffDelay(restartCount) to acquire pm.mu (or use a helper) after the sleep,
verify pm.autoRestart is still true (and optionally that pm.process == nil if
needed), only then call pm.Start(), and ensure any shared counters
(pm.restartCount) are consistent while holding pm.mu; reference pm.mu,
pm.autoRestart, pm.restartCount, backoffDelay and pm.Start when applying the
change.

In `@tray/service_darwin.go`:
- Around line 32-56: The plistTemplate uses text/template which does not escape
XML special chars; update the code that renders plistTemplate to use
html/template (or explicitly XML-escape values) so BinaryPath, ConfigPath and
LogDir are safely encoded for XML; locate the template variable/string named
plistTemplate and the rendering call that executes it (where BinaryPath,
ConfigPath, LogDir are supplied) and switch to html/template.New(...).Parse(...)
or run values through an XML-escaping helper before executing the template so
the generated plist is well-formed.
- Around line 127-137: serviceStatusCheck currently treats any successful exec
of "launchctl list <label>" as ServiceRunning; change it to run the command with
Output(), parse the returned stdout for the PID column, and only return
ServiceRunning when that PID field is a numeric PID; if the PID field is "-" (or
non-numeric) return ServiceRegistered. Specifically, in serviceStatusCheck use
exec.Command("launchctl","list", launchAgentLabel).Output(), trim/split the
output (the single-line listing has PID as the first field), check strconv.Atoi
(or regex) on the PID token to determine running vs registered, and fall back to
returning ServiceRegistered on parse errors. Keep the initial
os.Stat(servicePlistPath()) check unchanged.

In `@tray/service_linux.go`:
- Line 38: The ExecStart template must quote paths and escape systemd '%'
specifiers to avoid splitting on spaces or misinterpreting specifiers: change
the template to include quoted values (e.g.,
ExecStart="{{.BinaryPathEscaped}}"{{if .ConfigPath}} --config
"{{.ConfigPathEscaped}}"{{end}}) and add escaped versions of BinaryPath and
ConfigPath (e.g., BinaryPathEscaped, ConfigPathEscaped) that replace "%" with
"%%" (use strings.ReplaceAll) before rendering; update the code that builds the
template data so ExecStart uses these quoted/escaped fields.

In `@tray/service_windows.go`:
- Around line 28-31: The built command string uses cfg.BinaryPath and
cfg.ConfigPath directly (in the local variable command) which allows path
injection if either contains a double quote or other shell metacharacters; fix
by adding strict validation in ServiceConfig.Validate() to reject any paths
containing double quotes and other dangerous characters (e.g., '"', '&', '|',
'<', '>', ';') and return an error, and then rely on cfg.BinaryPath /
cfg.ConfigPath being safe before building the command in service_windows.go
(alternatively you can escape embedded quotes when constructing command, but
implement validation in ServiceConfig.Validate() and reference BinaryPath and
ConfigPath there).
- Around line 75-77: The code currently detects running tasks by matching the
localized substring "Running" (see the strings.Contains(..., "Running") check
that returns ServiceRunning), which fails on non-English Windows; change the
implementation that invokes schtasks to call `schtasks /query /tn <taskname>
/xml` (or use PowerShell `Get-ScheduledTask -TaskName <name> | Select-Object
-ExpandProperty State`) and parse the returned XML/enum instead of grepping
localized text: run schtasks with `/xml`, decode the XML using Go's
encoding/xml, extract the task state element (or use the PowerShell State value)
and map that canonical state to ServiceRunning/other statuses, replacing the
current strings.Contains-based check.

In `@tray/service.go`:
- Around line 56-62: The Validate() method on ServiceConfig currently only
checks BinaryPath emptiness; update ServiceConfig.Validate() to perform basic
path sanitization for both BinaryPath and ConfigPath by rejecting empty
BinaryPath and returning an error if either path contains any unsafe characters
(at minimum: '"', '<', '>', '%' and backticks or other shell/meta characters you
consider risky), and include clear error messages naming the offending field
(e.g., "BinaryPath contains invalid character: %q" or "ConfigPath contains
invalid character: %q"); keep the check centralized in ServiceConfig.Validate()
so platform implementations (macOS plist, Windows /TR, systemd) get this
defense-in-depth automatically.

---

Outside diff comments:
In `@output/webhook/webhook.go`:
- Around line 343-347: The response body can leak if io.ReadAll(resp.Body)
returns an error because defer resp.Body.Close() is registered after that call;
move the defer immediately after the nil-check for resp (i.e., right after the
resp == nil guard) so resp.Body.Close() is guaranteed on every return path,
keeping the existing io.ReadAll usage and error handling intact; update the
function that performs the HTTP call where resp, resp.Body and io.ReadAll are
used to register defer resp.Body.Close() before attempting to read the body.

---

Nitpick comments:
In `@tray/app.go`:
- Around line 144-145: The "About" menu handler currently only calls slog.Info
inside the mAbout.ClickedCh case which logs but shows nothing to users; replace
that logging call with a UI-visible action by invoking a function like
showAboutDialog or showNotification from the mAbout.ClickedCh branch (create it
if missing) that displays a small modal or system notification containing the
app name, version and brief description; ensure the new function is called
instead of slog.Info in the case block and reuse existing UI/notification
utilities in the codebase if available.

In `@tray/health.go`:
- Around line 82-94: The initial health check is delayed because
HealthPoller.run only starts the ticker; call hp.poll() once immediately before
entering the for/select loop so the first health poll occurs right away;
specifically, add an immediate hp.poll() invocation at the start of
HealthPoller.run (before creating or before the for loop) so subsequent
ticker-driven polls remain unchanged and hp.stopCh handling via the select stays
the same.

In `@tray/service_darwin.go`:
- Around line 104-108: Replace the legacy "launchctl load -w" invocation in the
install/start code (the exec.Command call that uses servicePlistPath()) with two
invocations that (1) run "launchctl enable <domain>/<label>" and (2) run
"launchctl bootstrap <domain> <plistPath>" using the per-user domain "gui/$(id
-u)" — obtain the uid programmatically (os.Getuid()) to form "gui/<uid>" and
execute both commands via exec.Command/CombinedOutput and preserve the existing
error wrapping/logging; likewise, replace the corresponding "launchctl unload"
in the uninstall/stop path with "launchctl bootout <domain> <plistPath>"
followed by "launchctl disable <domain>/<label>" (or disable the plist path)
using the same servicePlistPath() and uid-derived domain, ensuring you keep
CombinedOutput and the same error formatting (fmt.Errorf) on failures.

In `@tray/service_linux.go`:
- Around line 75-99: The registerService function currently enables the unit but
doesn't start it; after the existing systemctl --user enable step, invoke
systemctl --user start <serviceName> (using exec.Command and CombinedOutput like
the daemon-reload and enable calls) and propagate a formatted error including
the trimmed output on failure so the service is started immediately (or
alternatively add a comment/doc noting it only starts at next login); locate and
update registerService and use the existing serviceName symbol for the start
command.

Comment on lines +768 to 769
// #nosec G704 -- Kupo endpoint is user-configured and validated before use.
resp, err := httpClient.Do(req)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Same incorrect gosec rule ID G704 — should be G107.

Same issue as noted in input/mempool/mempool.go. The rule for HTTP request URL taint input is G107.

🔧 Proposed fix
-	// `#nosec` G704 -- Kupo endpoint is user-configured and validated before use.
+	// `#nosec` G107 -- Kupo endpoint is user-configured and validated before use.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// #nosec G704 -- Kupo endpoint is user-configured and validated before use.
resp, err := httpClient.Do(req)
// `#nosec` G107 -- Kupo endpoint is user-configured and validated before use.
resp, err := httpClient.Do(req)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@input/chainsync/chainsync.go` around lines 768 - 769, Replace the incorrect
gosec suppression tag above the HTTP call: change the comment "// `#nosec` G704 --
Kupo endpoint is user-configured and validated before use." to use the correct
rule ID "// `#nosec` G107 -- Kupo endpoint is user-configured and validated before
use." so the suppression applies to the tainted URL rule around the
httpClient.Do(req) call (the line where resp, err := httpClient.Do(req) is
invoked).

Comment on lines +77 to +81
fcmURL := &url.URL{
Scheme: "https",
Host: "fcm.googleapis.com",
Path: fmt.Sprintf("/v1/projects/%s/messages:send", projectId),
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

fd -g 'go.mod' --exec cat {}

Repository: blinklabs-io/adder

Length of output: 4886


🏁 Script executed:

cat -n output/push/fcm/message.go | sed -n '70,115p'

Repository: blinklabs-io/adder

Length of output: 1411


projectId interpolated into URL path without escaping — #nosec justification is inaccurate.

url.URL.Path stores the unescaped path; String() does not percent-encode /, so a projectId containing slashes or .. segments is treated as literal path components. For example, projectId = "foo/../../v2" would result in /v1/projects/foo/../../v2/messages:send on the wire.

The #nosec G704 comment claims "validated path," but no validation or escaping is applied — this misrepresents the code's security posture.

Fix with url.PathEscape:

-Path:   fmt.Sprintf("/v1/projects/%s/messages:send", projectId),
+Path:   fmt.Sprintf("/v1/projects/%s/messages:send", url.PathEscape(projectId)),

Or use url.JoinPath (Go 1.19+, which your project supports):

fcmURL, err := url.JoinPath("https://fcm.googleapis.com", "v1/projects", projectId, "messages:send")
if err != nil {
    return err
}

Update the #nosec comment to reflect the actual mitigation:

-// `#nosec` G704 -- Request targets the fixed FCM host with a validated path.
+// `#nosec` G704 -- Request targets a fixed external host; projectId is path-escaped.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@output/push/fcm/message.go` around lines 77 - 81, The URL path is built by
interpolating projectId into url.URL.Path which leaves it unescaped and the
current `#nosec G704` is incorrect; fix by percent-escaping or using safe join:
replace the manual url.URL construction for fcmURL with a call that escapes
projectId (e.g., use url.PathEscape(projectId) when building the Path or better
use url.JoinPath("https://fcm.googleapis.com", "v1/projects", projectId,
"messages:send") and handle the returned error), and update/remove the `#nosec`
comment so it accurately reflects the mitigation applied; locate and change the
code that constructs fcmURL and the `projectId` usage in message.go.

Comment on lines +335 to 336
// #nosec G704 -- Webhook URL is user-configured and intentionally allowed.
resp, err := client.Do(req)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the webhook.go file to see the context around lines 320-350
fd -n "webhook.go" | head -20

Repository: blinklabs-io/adder

Length of output: 292


🏁 Script executed:

# Get the actual file paths to locate webhook.go
git ls-files | grep -i webhook

Repository: blinklabs-io/adder

Length of output: 138


🏁 Script executed:

# Once we have the path, examine the code context
cat -n output/webhook/webhook.go | sed -n '320,350p'

Repository: blinklabs-io/adder

Length of output: 1378


🏁 Script executed:

# Also check line 323 to verify the existing nosec pattern
cat -n output/webhook/webhook.go | sed -n '320,330p'

Repository: blinklabs-io/adder

Length of output: 654


🌐 Web query:

G704 gosec rule SSRF taint analysis

💡 Result:

gosec rule G704 is the SSRF (Server-Side Request Forgery) detector implemented as a taint-analysis rule (G7xx = taint rules). [3]

What it flags (conceptually)

G704 reports an issue when tainted (externally influenced) data can reach a network/HTTP “sink” that initiates an outbound request / connection. [1][3]

Taint sources used by G704

G704’s SSRF taint config treats data as tainted when it originates from, or flows from: [1]

  • *net/http.Request (as a parameter type source)
  • os.Args and os.Getenv (function sources)
  • *bufio.Reader, *bufio.Scanner (I/O sources)

Sinks checked by G704 (where tainted data is dangerous)

The rule checks specific arguments (“CheckArgs”) for taint at these sinks, including: [1]

  • net/http: http.Get, Post, Head, PostForm (URL argument)
  • net/http: NewRequest, NewRequestWithContext (URL argument position)
  • (*http.Client): Do, Get, Post, Head
  • net: Dial, DialTimeout (address argument), LookupHost (host argument)
  • net/http/httputil: NewSingleHostReverseProxy (target URL argument)

Sanitizers

The SSRF rule config effectively ships with no standard-library sanitizers (it explicitly notes that parsing like url.Parse does not make something SSRF-safe because it doesn’t enforce an allowlist). [1]

Examples (from gosec tests)

  • Flagged: user-controlled query param used as http.Get(url) [2]
  • Flagged: os.Getenv("TARGET_URL") used as http.Post(target, ...) [2]
  • Not flagged: hardcoded URL in http.Get("https://api.example.com/data") [2]

🏁 Script executed:

# Search for URL validation patterns in chainsync and mempool modules mentioned in the review
fd -n "\.go$" | xargs rg -l "url\.Parse|scheme.*http|urlutil" | head -20

Repository: blinklabs-io/adder

Length of output: 402


🏁 Script executed:

# Check for URL scheme validation in the codebase
rg "Scheme.*http|scheme.*http" -A 2 -B 2 | head -50

Repository: blinklabs-io/adder

Length of output: 1100


🏁 Script executed:

# Find where w.url is set in webhook.go
cat -n output/webhook/webhook.go | grep -E "w\.url|\.url\s*=" | head -20

Repository: blinklabs-io/adder

Length of output: 258


🏁 Script executed:

# Look at the WebhookOutput struct definition to see w.url
rg "type WebhookOutput" -A 10 output/webhook/webhook.go

Repository: blinklabs-io/adder

Length of output: 353


🏁 Script executed:

# Check mempool.go for URL validation
cat -n input/mempool/mempool.go | sed -n '1,100p' | grep -E "Scheme|http|https"

Repository: blinklabs-io/adder

Length of output: 137


🏁 Script executed:

# Get a broader view of mempool URL handling
rg "http\.|url\.|Parse" -B 2 -A 2 input/mempool/mempool.go | head -40

Repository: blinklabs-io/adder

Length of output: 1056


🏁 Script executed:

# Check the SendWebhook function signature and full context
cat -n output/webhook/webhook.go | sed -n '300,360p'

Repository: blinklabs-io/adder

Length of output: 2203


🏁 Script executed:

# Find where WebhookOutput is instantiated or w.url is set
rg "WebhookOutput\{|\.url\s*=" output/webhook/ -B 2 -A 2

Repository: blinklabs-io/adder

Length of output: 613


🏁 Script executed:

# Check the options or initialization code
cat -n output/webhook/options.go | head -80

Repository: blinklabs-io/adder

Length of output: 2379


🏁 Script executed:

# Look at where webhook URL comes from in configuration
rg "url" output/webhook/options.go -B 2 -A 2

Repository: blinklabs-io/adder

Length of output: 239


Address the response body leak and consider adding URL validation for consistency with other modules.

The G704 suppression is justified for a config-only URL, but two issues remain:

  1. Response body leak (lines 343–347): The defer resp.Body.Close() is placed after io.ReadAll(). If io.ReadAll() fails, the early return at line 345 exits without ever closing the body, causing a resource leak. Move the defer before the ReadAll call.

  2. Missing URL validation: Other modules in this PR (chainsync, mempool) added explicit URL scheme validation before making HTTP calls (e.g., rejecting non-http/https schemes). The webhook output applies no such check to w.url. While the config-only justification for the nosec is sound, adding validation would improve consistency and defense-in-depth, especially since mempool's nosec comment explicitly notes "validated before use."

Example fix
 	respBody, err := io.ReadAll(resp.Body)
+	defer resp.Body.Close()
 	if err != nil {
 		return fmt.Errorf("%w", err)
 	}
-	defer resp.Body.Close()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@output/webhook/webhook.go` around lines 335 - 336, Move the response body
close so it cannot leak: after calling client.Do(req) and before calling
io.ReadAll, immediately check err and then defer resp.Body.Close() (so resp.Body
is always closed even if ReadAll fails); also add URL scheme validation for
w.url (the value used to build the request) similar to chainsync/mempool modules
by parsing w.url and rejecting any non-http/https scheme prior to making
client.Do(req) to ensure consistent defense-in-depth.

Comment on lines 55 to 61
process: NewProcessManager(
WithBinary(cfg.AdderBinary),
WithConfigFile(cfg.AdderConfig),
WithStatusTracker(status),
WithAPIEndpoint(defaultAPIAddress, defaultAPIPort),
WithAutoRestart(true),
),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's locate and examine the tray/process.go file
fd process.go tray/

Repository: blinklabs-io/adder

Length of output: 77


🏁 Script executed:

# Check the file size before reading
wc -l tray/process.go

Repository: blinklabs-io/adder

Length of output: 81


🏁 Script executed:

# Now let's examine the Stop() method and surrounding context
sed -n '300,320p' tray/process.go

Repository: blinklabs-io/adder

Length of output: 533


🏁 Script executed:

# Let's also check the Start() method implementation
ast-grep --pattern $'func (pm *ProcessManager) Start() error {
  $$$
}'

Repository: blinklabs-io/adder

Length of output: 3354


🏁 Script executed:

# Let's search for all mentions of autoRestart to understand its usage
rg 'autoRestart' tray/process.go -n -B 2 -A 2

Repository: blinklabs-io/adder

Length of output: 614


Auto-restart is permanently disabled after an explicit Stop.

WithAutoRestart(true) is set at construction (line 60), but Stop() in tray/process.go (line 306) permanently sets pm.autoRestart = false. After the user clicks Stop → Start, auto-restart remains disabled for the rest of the session. Start() (lines 133-212) never re-enables it.

If the intent is that auto-restart should only be suppressed for the current stop cycle, Start() should restore autoRestart to its configured value.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tray/app.go` around lines 55 - 61, The process manager is constructed with
WithAutoRestart(true) via NewProcessManager but Stop() in tray/process.go sets
pm.autoRestart = false permanently, and Start() never restores it; change
Start() (the Start method on the ProcessManager) to reset pm.autoRestart to the
configured default when starting (e.g., reapply the configured autoRestart flag
stored on the manager or re-run the constructor-provided option value) so that a
Stop→Start only suppresses auto-restart for that stop cycle rather than
disabling it for the session; reference NewProcessManager, WithAutoRestart,
Stop(), Start(), and pm.autoRestart to locate the relevant code.

Comment on lines +51 to +53
func (ep *EventParser) Start() {
go ep.run()
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

No guard against double Start() — calling it twice will panic.

If Start() is called twice, two goroutines will race on the same bufio.Scanner, and close(ep.events) in run() will execute twice, causing a panic. Consider adding a sync.Once or a started flag.

🛡️ Proposed fix using sync.Once
 type EventParser struct {
 	scanner *bufio.Scanner
 	events  chan event.Event
 	done    chan struct{}
+	once    sync.Once
 }
 func (ep *EventParser) Start() {
-	go ep.run()
+	ep.once.Do(func() {
+		go ep.run()
+	})
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (ep *EventParser) Start() {
go ep.run()
}
func (ep *EventParser) Start() {
ep.once.Do(func() {
go ep.run()
})
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tray/events.go` around lines 51 - 53, Start() currently launches goroutine
unguarded which lets multiple calls race on the same bufio.Scanner and cause
close(ep.events) to run twice; add a guard (preferably a sync.Once field like
startOnce on EventParser) and change Start() to call startOnce.Do(func(){ go
ep.run() }) so run() is only started once (this avoids double-closing ep.events
and races on the scanner); ensure the new startOnce field is initialized with
zero value and reference EventParser.Start(), EventParser.run(), and ep.events
in the change.

Comment on lines +127 to +137
func serviceStatusCheck() (ServiceStatus, error) {
if _, err := os.Stat(servicePlistPath()); os.IsNotExist(err) {
return ServiceNotRegistered, nil
}

if err := exec.Command("launchctl", "list", launchAgentLabel).Run(); err == nil {
return ServiceRunning, nil
}

return ServiceRegistered, nil
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, let's examine the actual file and surrounding context
find . -name "service_darwin.go" -o -name "service_linux.go" | head -20

Repository: blinklabs-io/adder

Length of output: 110


🏁 Script executed:

#!/bin/bash
# Get the full context of the darwin service implementation
cat -n tray/service_darwin.go | head -150

Repository: blinklabs-io/adder

Length of output: 4779


🏁 Script executed:

#!/bin/bash
# Look for the Linux implementation to compare
cat -n tray/service_linux.go 2>/dev/null | head -150

Repository: blinklabs-io/adder

Length of output: 4696


🏁 Script executed:

#!/bin/bash
# Search for any other launchctl usages in the codebase
rg -n 'launchctl' --type=go

Repository: blinklabs-io/adder

Length of output: 319


🌐 Web query:

launchctl list output format PID status crashed job

💡 Result:

launchctl list prints three columns:

PID   Status   Label
  • PID: the process ID if the job is currently running; otherwise -. [1]
  • Status: the job’s last exit status. 0 usually means it last exited successfully (or may not have run yet). [1][5]
    • If negative, it means the job was terminated by a signal; the value is the negative of the signal number (e.g., -15 = SIGTERM). [1][3]
  • Label: the job identifier (what you use with other launchctl commands). [1]

So “crashed” is typically inferred from Status ≠ 0 (or a negative signal-based status), not shown as a separate column. [1][3]

Sources: [1] [3] [5]


🌐 Web query:

macOS launchctl list exit code running vs registered job

💡 Result:

launchctl list prints three columns:

  • PID: the job’s process ID if it’s currently running; otherwise it shows - (loaded/registered but not running). [1][4][5]
  • Status: the job’s most recent exit status (aka “last exit code”). If it’s negative, it usually means the job was terminated by a signal (e.g., -15 = SIGTERM). [1]
  • Label: the job’s label from its plist. [5]

So, interpreting “running vs registered/loaded”:

  • Running job: PID is a number. [1][2]
  • Loaded/registered but not running: PID is -. [4][5]
  • Status = 0 with PID = -: often means either it exited successfully last time or it hasn’t run yet (so “0” doesn’t prove it ran). [2]

If you need a clearer state than list provides, use the modern detailed view:

  • launchctl print <domain>/<label> (e.g., launchctl print gui/501/com.example.job), which can show fields like state = running, pid = …, and last exit code = …. [6]

Sources: [1] [2] [4] [5] [6]


Parse launchctl list output to distinguish running from registered services.

launchctl list <label> returns exit code 0 for any known job, whether it's running or stopped. To distinguish ServiceRunning from ServiceRegistered, parse the output's PID column: a numeric PID indicates the service is running, while - indicates it's registered but not running. A crashed or stopped service incorrectly returns ServiceRunning with the current code.

Align with the Linux implementation pattern, which parses systemctl is-active output for precise status reporting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tray/service_darwin.go` around lines 127 - 137, serviceStatusCheck currently
treats any successful exec of "launchctl list <label>" as ServiceRunning; change
it to run the command with Output(), parse the returned stdout for the PID
column, and only return ServiceRunning when that PID field is a numeric PID; if
the PID field is "-" (or non-numeric) return ServiceRegistered. Specifically, in
serviceStatusCheck use exec.Command("launchctl","list",
launchAgentLabel).Output(), trim/split the output (the single-line listing has
PID as the first field), check strconv.Atoi (or regex) on the PID token to
determine running vs registered, and fall back to returning ServiceRegistered on
parse errors. Keep the initial os.Stat(servicePlistPath()) check unchanged.


[Service]
Type=simple
ExecStart={{.BinaryPath}}{{if .ConfigPath}} --config {{.ConfigPath}}{{end}}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

ExecStart will break if BinaryPath or ConfigPath contain spaces or systemd specifiers.

Systemd interprets % as a specifier prefix (e.g., %h = home dir) and splits on unquoted whitespace in ExecStart. If either path contains spaces or %, the unit will fail to start or behave unexpectedly. Paths should be quoted in the template, and % should be escaped as %%.

Proposed fix for space handling
-ExecStart={{.BinaryPath}}{{if .ConfigPath}} --config {{.ConfigPath}}{{end}}
+ExecStart="{{.BinaryPath}}"{{if .ConfigPath}} --config "{{.ConfigPath}}"{{end}}

Note: This doesn't address % specifier escaping. If paths may contain %, you'll need a template function to replace % with %%.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ExecStart={{.BinaryPath}}{{if .ConfigPath}} --config {{.ConfigPath}}{{end}}
ExecStart="{{.BinaryPath}}"{{if .ConfigPath}} --config "{{.ConfigPath}}"{{end}}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tray/service_linux.go` at line 38, The ExecStart template must quote paths
and escape systemd '%' specifiers to avoid splitting on spaces or
misinterpreting specifiers: change the template to include quoted values (e.g.,
ExecStart="{{.BinaryPathEscaped}}"{{if .ConfigPath}} --config
"{{.ConfigPathEscaped}}"{{end}}) and add escaped versions of BinaryPath and
ConfigPath (e.g., BinaryPathEscaped, ConfigPathEscaped) that replace "%" with
"%%" (use strings.ReplaceAll) before rendering; update the code that builds the
template data so ExecStart uses these quoted/escaped fields.

Comment on lines +28 to +31
command := fmt.Sprintf(`"%s"`, cfg.BinaryPath)
if cfg.ConfigPath != "" {
command = fmt.Sprintf(`"%s" --config "%s"`, cfg.BinaryPath, cfg.ConfigPath)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Command string in /TR is vulnerable to path injection.

BinaryPath and ConfigPath are embedded into a single command string using fmt.Sprintf with only quote wrapping. If either path contains a double quote ("), the command string breaks and could allow arbitrary command execution. The //nolint:gosec comment claims "validated config," but ServiceConfig.Validate() in tray/service.go (line 57–62) only checks that BinaryPath is non-empty — it does not sanitize path characters.

Consider either validating that paths don't contain special characters in Validate(), or escaping embedded quotes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tray/service_windows.go` around lines 28 - 31, The built command string uses
cfg.BinaryPath and cfg.ConfigPath directly (in the local variable command) which
allows path injection if either contains a double quote or other shell
metacharacters; fix by adding strict validation in ServiceConfig.Validate() to
reject any paths containing double quotes and other dangerous characters (e.g.,
'"', '&', '|', '<', '>', ';') and return an error, and then rely on
cfg.BinaryPath / cfg.ConfigPath being safe before building the command in
service_windows.go (alternatively you can escape embedded quotes when
constructing command, but implement validation in ServiceConfig.Validate() and
reference BinaryPath and ConfigPath there).

Comment on lines +75 to +77
if strings.Contains(string(out), "Running") {
return ServiceRunning, nil
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

schtasks.exe output locale dependent Windows non-English

💡 Result:

schtasks.exe output is locale-dependent on non‑English Windows (column headers, some field values, and even some prompts), so parsing its human-readable output is brittle.

Practical ways to make it locale-robust

  1. Prefer XML output (best for locale-independence)

    • schtasks /query /xml emits task definitions as XML, which you can parse reliably regardless of UI language. (learn.microsoft.com)
  2. If you must parse text, avoid localized headers

    • Use CSV without headers and provide your own fixed headers in your script:
      • schtasks /query /fo csv /nh
    • This avoids localized column names (which break ConvertFrom-Csv if you rely on the first line). (learn.microsoft.com)
    • Caveat: some values inside the CSV can still be localized (for example, status/state strings), so avoid string-matching on those when possible.
  3. Use APIs / PowerShell cmdlets instead of schtasks (often the most robust)

    • For scripting, Task Scheduler APIs (or PowerShell scheduled-task cmdlets where available) return structured properties that don’t depend on localized display text (unlike schtasks).

Related “locale gotcha”

  • Historically, schtasks confirmation prompts could display localized “Yes/No” keys but only accept Y/N on some systems (old Windows XP issue). (betaarchive.com)

Citations:


🌐 Web query:

schtasks.exe Running status localization German Japanese

💡 Result:

schtasks.exe /query output is localized (both the column headers and the status values) according to the language/UI culture of the machine/session you run it in (examples show “Statut : Prêt” in French, and Japanese headers when parsing /FO CSV). [1], [2]

“Running” status string

If you’re parsing the Status column/value, “Running” appears as:

  • German (de-DE): Wird ausgeführt [3]
  • Japanese (ja-JP): 実行中 [4]

Practical implication (recommended)

Because these strings vary by locale, avoid string-matching schtasks /query text. Prefer one of:

  • schtasks /query /xml and parse the XML instead of localized table/list text. [5]
  • Or use PowerShell scheduled-task APIs (e.g., Get-ScheduledTask) which expose a non-localized State value (enum) rather than localized strings (not from schtasks output).

Sources:
[1] Stack Overflow discussion showing localized schtasks fields (e.g., “Statut : Prêt”) https://stackoverflow.com/questions/13530473/schtasks-output-is-in-different-encoding-whether-it-runs-locally-or-in-remote-se
[2] Blog example parsing schtasks.exe /query /V /FO CSV with Japanese column names https://www.jluvl.com/2018/02/windows.html
[3] IBM Docs (German UI) showing status changes to “Wird ausgeführt” while running https://www.ibm.com/docs/de/SSEKCU_1.1.2.1/com.ibm.psc.doc/admin/as_t_admin_view_task_status_gui.html
[4] Microsoft Learn (Japanese) using 状態 = “実行中” https://learn.microsoft.com/ja-jp/entra/identity/hybrid/connect/how-to-connect-sync-feature-scheduler
[5] Microsoft Learn: schtasks query supports /xml output https://learn.microsoft.com/windows-server/administration/windows-commands/schtasks-query


🌐 Web query:

schtasks.exe /V parameter verbose output locale

💡 Result:

  • schtasks.exe /Query /V (/v is case-insensitive) enables verbose task output (adds advanced properties). Microsoft notes /v is only valid with /FO LIST or /FO CSV. [1]
  • schtasks does not provide a switch to force the output language/locale. The human-readable field names (and often date/time formatting) come out localized based on the environment where schtasks.exe runs, which makes /V output inherently locale-dependent for parsing. [2]
  • If you need locale-stable / machine-parseable output, prefer:
    • schtasks /Query /TN "<task>" /XML (XML task definition output). [1]
    • Or avoid schtasks text output entirely and query via the Task Scheduler API (e.g., the Schedule.Service COM object) to get language-independent properties. [2]

Sources
[1] Microsoft Learn — schtasks query (/v, /fo, /xml) https://learn.microsoft.com/windows-server/administration/windows-commands/schtasks-query
[2] Stack Overflow — discussion showing locale/encoding-dependent schtasks output and recommending Schedule.Service COM API https://stackoverflow.com/questions/13530473/schtasks-output-is-in-different-encoding-whether-it-runs-locally-or-in-remote-se


"Running" status string is locale-dependent on non-English Windows and must not be matched as a string.

schtasks.exe localizes the status output based on the OS locale. In German Windows it returns "Wird ausgeführt" and in Japanese "実行中". The /V flag does not solve this—it remains localized. Instead, use schtasks /query /xml to get locale-independent XML output, or switch to PowerShell's Get-ScheduledTask cmdlet (returns non-localized State enum), or query the Task Scheduler API via COM (Schedule.Service COM object).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tray/service_windows.go` around lines 75 - 77, The code currently detects
running tasks by matching the localized substring "Running" (see the
strings.Contains(..., "Running") check that returns ServiceRunning), which fails
on non-English Windows; change the implementation that invokes schtasks to call
`schtasks /query /tn <taskname> /xml` (or use PowerShell `Get-ScheduledTask
-TaskName <name> | Select-Object -ExpandProperty State`) and parse the returned
XML/enum instead of grepping localized text: run schtasks with `/xml`, decode
the XML using Go's encoding/xml, extract the task state element (or use the
PowerShell State value) and map that canonical state to ServiceRunning/other
statuses, replacing the current strings.Contains-based check.

Comment on lines +56 to +62
// Validate checks that the ServiceConfig contains the minimum required fields.
func (c ServiceConfig) Validate() error {
if c.BinaryPath == "" {
return errors.New("binary path must not be empty")
}
return nil
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Validate() should sanitize paths to prevent platform-specific injection issues.

As flagged in the platform implementations, unsanitized BinaryPath and ConfigPath values can cause XML injection (macOS plist), command injection (Windows /TR), or systemd specifier issues (Linux %). Adding basic path validation here (e.g., rejecting characters like ", <, >, %) would provide defense-in-depth at the shared layer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tray/service.go` around lines 56 - 62, The Validate() method on ServiceConfig
currently only checks BinaryPath emptiness; update ServiceConfig.Validate() to
perform basic path sanitization for both BinaryPath and ConfigPath by rejecting
empty BinaryPath and returning an error if either path contains any unsafe
characters (at minimum: '"', '<', '>', '%' and backticks or other shell/meta
characters you consider risky), and include clear error messages naming the
offending field (e.g., "BinaryPath contains invalid character: %q" or
"ConfigPath contains invalid character: %q"); keep the check centralized in
ServiceConfig.Validate() so platform implementations (macOS plist, Windows /TR,
systemd) get this defense-in-depth automatically.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

12 issues found across 17 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="output/push/fcm/message.go">

<violation number="1" location="output/push/fcm/message.go:80">
P2: `projectId` is interpolated into the URL path without escaping. A `projectId` containing `/` or `..` segments (e.g., `"foo/../../v2"`) would result in path traversal. Use `url.PathEscape(projectId)` to prevent this.</violation>
</file>

<file name="tray/events.go">

<violation number="1" location="tray/events.go:51">
P2: No guard against double `Start()` — calling it twice will launch two goroutines racing on the same `bufio.Scanner`, and `close(ep.events)` in `run()` will execute twice, causing a panic. Add a `sync.Once` or a started flag to prevent this.</violation>

<violation number="2" location="tray/events.go:57">
P2: Stop doesn’t actually stop the parser if the reader is blocked; the goroutine can hang indefinitely because `scanner.Scan()` isn’t interruptible. Consider closing the underlying reader or adding a cancelable read mechanism so Stop reliably terminates the loop.</violation>
</file>

<file name="tray/health.go">

<violation number="1" location="tray/health.go:112">
P2: Handle non-200 HTTP status codes as failures before decoding the body; otherwise a 500/404 response can be treated as healthy and reset the failure counter.</violation>
</file>

<file name="tray/service_windows.go">

<violation number="1" location="tray/service_windows.go:30">
P2: `BinaryPath` and `ConfigPath` are embedded into the `/TR` command string with only quote wrapping. If either path contains a double quote (`"`), the command string breaks and could allow arbitrary command execution. `ServiceConfig.Validate()` only checks that `BinaryPath` is non-empty — it does not sanitize path characters.</violation>

<violation number="2" location="tray/service_windows.go:42">
P2: Don’t swallow schtasks query errors. A failed query (permissions, command failure) should return an error instead of reporting “not registered,” otherwise the tray can misreport service state.</violation>

<violation number="3" location="tray/service_windows.go:75">
P2: The `"Running"` status string is locale-dependent on non-English Windows. For example, German Windows returns `"Wird ausgeführt"` and Japanese returns `"実行中"`. This check will always fail on non-English systems. Use `schtasks /query /xml` for locale-independent XML output, or switch to PowerShell's `Get-ScheduledTask` cmdlet.</violation>
</file>

<file name="tray/process.go">

<violation number="1" location="tray/process.go:270">
P1: Race condition: scheduled auto-restart will still fire after an explicit `Stop()` call. The restart goroutine captures the decision to restart before sleeping, but `Stop()` sets `autoRestart = false` during the sleep window. Since the goroutine never re-checks `autoRestart` after waking, the process restarts despite being explicitly stopped.</violation>

<violation number="2" location="tray/process.go:306">
P1: Auto-restart is permanently disabled after an explicit `Stop()`. `Stop()` sets `pm.autoRestart = false`, but `Start()` never re-enables it. After a user clicks Stop → Start, the process will no longer auto-restart on crash for the rest of the session. `Start()` should restore `autoRestart` to its configured value.</violation>
</file>

<file name="tray/service_linux.go">

<violation number="1" location="tray/service_linux.go:38">
P2: Quote/escape BinaryPath and ConfigPath in the systemd unit so paths with spaces don't break ExecStart parsing.</violation>
</file>

<file name="tray/service_darwin.go">

<violation number="1" location="tray/service_darwin.go:40">
P2: Values in the LaunchAgent plist are injected without XML escaping. If BinaryPath/ConfigPath/LogDir contains characters like `&` or `<`, the generated plist becomes invalid (and can be abused to inject extra plist keys). Escape these values or render the plist via an XML/plist encoder before writing.</violation>

<violation number="2" location="tray/service_darwin.go:132">
P2: `launchctl list <label>` returns exit code 0 for any known (loaded) job, whether it's currently running or stopped. This means a crashed or stopped service incorrectly returns `ServiceRunning`. Parse the output's PID column instead: a numeric PID indicates running, while `-` indicates registered but not running.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment on lines +270 to +271
go func() {
time.Sleep(delay)
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Race condition: scheduled auto-restart will still fire after an explicit Stop() call. The restart goroutine captures the decision to restart before sleeping, but Stop() sets autoRestart = false during the sleep window. Since the goroutine never re-checks autoRestart after waking, the process restarts despite being explicitly stopped.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tray/process.go, line 270:

<comment>Race condition: scheduled auto-restart will still fire after an explicit `Stop()` call. The restart goroutine captures the decision to restart before sleeping, but `Stop()` sets `autoRestart = false` during the sleep window. Since the goroutine never re-checks `autoRestart` after waking, the process restarts despite being explicitly stopped.</comment>

<file context>
@@ -95,24 +173,126 @@ func (pm *ProcessManager) Start() error {
+			"restart_count", restartCount+1,
+		)
+
+		go func() {
+			time.Sleep(delay)
+			if err := pm.Start(); err != nil {
</file context>
Suggested change
go func() {
time.Sleep(delay)
go func() {
time.Sleep(delay)
pm.mu.Lock()
if !pm.autoRestart {
pm.mu.Unlock()
slog.Info("automatic restart cancelled")
return
}
pm.mu.Unlock()
Fix with Cubic

}

// Disable auto-restart when explicitly stopped
pm.autoRestart = false
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Auto-restart is permanently disabled after an explicit Stop(). Stop() sets pm.autoRestart = false, but Start() never re-enables it. After a user clicks Stop → Start, the process will no longer auto-restart on crash for the rest of the session. Start() should restore autoRestart to its configured value.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tray/process.go, line 306:

<comment>Auto-restart is permanently disabled after an explicit `Stop()`. `Stop()` sets `pm.autoRestart = false`, but `Start()` never re-enables it. After a user clicks Stop → Start, the process will no longer auto-restart on crash for the rest of the session. `Start()` should restore `autoRestart` to its configured value.</comment>

<file context>
@@ -122,6 +302,9 @@ func (pm *ProcessManager) Stop() error {
 	}
 
+	// Disable auto-restart when explicitly stopped
+	pm.autoRestart = false
+
 	slog.Info("stopping adder process", "pid", pm.cmd.Process.Pid)
</file context>
Fix with Cubic

fcmURL := &url.URL{
Scheme: "https",
Host: "fcm.googleapis.com",
Path: fmt.Sprintf("/v1/projects/%s/messages:send", projectId),
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: projectId is interpolated into the URL path without escaping. A projectId containing / or .. segments (e.g., "foo/../../v2") would result in path traversal. Use url.PathEscape(projectId) to prevent this.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At output/push/fcm/message.go, line 80:

<comment>`projectId` is interpolated into the URL path without escaping. A `projectId` containing `/` or `..` segments (e.g., `"foo/../../v2"`) would result in path traversal. Use `url.PathEscape(projectId)` to prevent this.</comment>

<file context>
@@ -73,25 +74,24 @@ func NewMessage(token string, opts ...MessageOption) (*Message, error) {
+	fcmURL := &url.URL{
+		Scheme: "https",
+		Host:   "fcm.googleapis.com",
+		Path:   fmt.Sprintf("/v1/projects/%s/messages:send", projectId),
+	}
 
</file context>
Suggested change
Path: fmt.Sprintf("/v1/projects/%s/messages:send", projectId),
Path: fmt.Sprintf("/v1/projects/%s/messages:send", url.PathEscape(projectId)),
Fix with Cubic

// Start begins parsing events in a background goroutine. The events
// channel is closed when the reader returns EOF or an error, or when
// Stop is called.
func (ep *EventParser) Start() {
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: No guard against double Start() — calling it twice will launch two goroutines racing on the same bufio.Scanner, and close(ep.events) in run() will execute twice, causing a panic. Add a sync.Once or a started flag to prevent this.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tray/events.go, line 51:

<comment>No guard against double `Start()` — calling it twice will launch two goroutines racing on the same `bufio.Scanner`, and `close(ep.events)` in `run()` will execute twice, causing a panic. Add a `sync.Once` or a started flag to prevent this.</comment>

<file context>
@@ -0,0 +1,104 @@
+// Start begins parsing events in a background goroutine. The events
+// channel is closed when the reader returns EOF or an error, or when
+// Stop is called.
+func (ep *EventParser) Start() {
+	go ep.run()
+}
</file context>
Fix with Cubic

}
defer resp.Body.Close()

var hr HealthResponse
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Handle non-200 HTTP status codes as failures before decoding the body; otherwise a 500/404 response can be treated as healthy and reset the failure counter.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tray/health.go, line 112:

<comment>Handle non-200 HTTP status codes as failures before decoding the body; otherwise a 500/404 response can be treated as healthy and reset the failure counter.</comment>

<file context>
@@ -0,0 +1,147 @@
+	}
+	defer resp.Body.Close()
+
+	var hr HealthResponse
+	if err := json.NewDecoder(resp.Body).Decode(&hr); err != nil {
+		hp.consecutiveFailures++
</file context>
Fix with Cubic


// Stop signals the event parser to stop. Note that the parser will
// also stop naturally when the underlying reader is closed.
func (ep *EventParser) Stop() {
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Stop doesn’t actually stop the parser if the reader is blocked; the goroutine can hang indefinitely because scanner.Scan() isn’t interruptible. Consider closing the underlying reader or adding a cancelable read mechanism so Stop reliably terminates the loop.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tray/events.go, line 57:

<comment>Stop doesn’t actually stop the parser if the reader is blocked; the goroutine can hang indefinitely because `scanner.Scan()` isn’t interruptible. Consider closing the underlying reader or adding a cancelable read mechanism so Stop reliably terminates the loop.</comment>

<file context>
@@ -0,0 +1,104 @@
+
+// Stop signals the event parser to stop. Note that the parser will
+// also stop naturally when the underlying reader is closed.
+func (ep *EventParser) Stop() {
+	select {
+	case <-ep.done:
</file context>
Fix with Cubic

return ServiceNotRegistered, nil
}

if err := exec.Command("launchctl", "list", launchAgentLabel).Run(); err == nil {
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: launchctl list <label> returns exit code 0 for any known (loaded) job, whether it's currently running or stopped. This means a crashed or stopped service incorrectly returns ServiceRunning. Parse the output's PID column instead: a numeric PID indicates running, while - indicates registered but not running.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tray/service_darwin.go, line 132:

<comment>`launchctl list <label>` returns exit code 0 for any known (loaded) job, whether it's currently running or stopped. This means a crashed or stopped service incorrectly returns `ServiceRunning`. Parse the output's PID column instead: a numeric PID indicates running, while `-` indicates registered but not running.</comment>

<file context>
@@ -0,0 +1,137 @@
+		return ServiceNotRegistered, nil
+	}
+
+	if err := exec.Command("launchctl", "list", launchAgentLabel).Run(); err == nil {
+		return ServiceRunning, nil
+	}
</file context>
Fix with Cubic

return ServiceNotRegistered, nil
}

if strings.Contains(string(out), "Running") {
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The "Running" status string is locale-dependent on non-English Windows. For example, German Windows returns "Wird ausgeführt" and Japanese returns "実行中". This check will always fail on non-English systems. Use schtasks /query /xml for locale-independent XML output, or switch to PowerShell's Get-ScheduledTask cmdlet.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tray/service_windows.go, line 75:

<comment>The `"Running"` status string is locale-dependent on non-English Windows. For example, German Windows returns `"Wird ausgeführt"` and Japanese returns `"実行中"`. This check will always fail on non-English systems. Use `schtasks /query /xml` for locale-independent XML output, or switch to PowerShell's `Get-ScheduledTask` cmdlet.</comment>

<file context>
@@ -0,0 +1,80 @@
+		return ServiceNotRegistered, nil
+	}
+
+	if strings.Contains(string(out), "Running") {
+		return ServiceRunning, nil
+	}
</file context>
Fix with Cubic

func registerService(cfg ServiceConfig) error {
command := fmt.Sprintf(`"%s"`, cfg.BinaryPath)
if cfg.ConfigPath != "" {
command = fmt.Sprintf(`"%s" --config "%s"`, cfg.BinaryPath, cfg.ConfigPath)
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: BinaryPath and ConfigPath are embedded into the /TR command string with only quote wrapping. If either path contains a double quote ("), the command string breaks and could allow arbitrary command execution. ServiceConfig.Validate() only checks that BinaryPath is non-empty — it does not sanitize path characters.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tray/service_windows.go, line 30:

<comment>`BinaryPath` and `ConfigPath` are embedded into the `/TR` command string with only quote wrapping. If either path contains a double quote (`"`), the command string breaks and could allow arbitrary command execution. `ServiceConfig.Validate()` only checks that `BinaryPath` is non-empty — it does not sanitize path characters.</comment>

<file context>
@@ -0,0 +1,80 @@
+func registerService(cfg ServiceConfig) error {
+	command := fmt.Sprintf(`"%s"`, cfg.BinaryPath)
+	if cfg.ConfigPath != "" {
+		command = fmt.Sprintf(`"%s" --config "%s"`, cfg.BinaryPath, cfg.ConfigPath)
+	}
+
</file context>
Fix with Cubic

<string>io.blinklabs.adder</string>
<key>ProgramArguments</key>
<array>
<string>{{.BinaryPath}}</string>
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Values in the LaunchAgent plist are injected without XML escaping. If BinaryPath/ConfigPath/LogDir contains characters like & or <, the generated plist becomes invalid (and can be abused to inject extra plist keys). Escape these values or render the plist via an XML/plist encoder before writing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tray/service_darwin.go, line 40:

<comment>Values in the LaunchAgent plist are injected without XML escaping. If BinaryPath/ConfigPath/LogDir contains characters like `&` or `<`, the generated plist becomes invalid (and can be abused to inject extra plist keys). Escape these values or render the plist via an XML/plist encoder before writing.</comment>

<file context>
@@ -0,0 +1,137 @@
+    <string>io.blinklabs.adder</string>
+    <key>ProgramArguments</key>
+    <array>
+        <string>{{.BinaryPath}}</string>
+        {{- if .ConfigPath}}
+        <string>--config</string>
</file context>
Fix with Cubic

@wolf31o2
Copy link
Copy Markdown
Member Author

Closing: this PR is stale (merge conflicts with main) and its enhancements (enhanced menu, status display, config/logs folders, about dialog) are captured in the TRAY_WS_REWRITE_PLAN.md Tasks C-H. The menu enhancements will be re-implemented as part of Task E (App Lifecycle Refactoring) using the new ConnectionManager architecture.

@wolf31o2 wolf31o2 closed this Feb 28, 2026
@wolf31o2 wolf31o2 deleted the feature/tray-enhanced-menu branch March 2, 2026 20:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant