Skip to content

fix(daemon): forward proxy env vars into managed daemon service definitions#322

Merged
kunchenguid merged 14 commits into
kunchenguid:mainfrom
TheIanLi:fix/forward-proxy-env-to-daemon-service
Jun 30, 2026
Merged

fix(daemon): forward proxy env vars into managed daemon service definitions#322
kunchenguid merged 14 commits into
kunchenguid:mainfrom
TheIanLi:fix/forward-proxy-env-to-daemon-service

Conversation

@TheIanLi

@TheIanLi TheIanLi commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

How this was found

While running no-mistakes from a WSL shell behind a local HTTP proxy, every agent step (review, test) failed with Failed to authenticate. API Error: 403 Request not allowed — even though claude --print worked fine in an interactive shell. Reproducing the daemon-spawned agent's minimal environment (env -i HOME=… PATH=… claude --print) reproduced the 403; adding the proxy variables back made it succeed. The managed daemon's systemd unit only exports HOME and PATH, so the proxy configuration the user relies on never reaches the daemon — or the agents it spawns. This PR forwards it.


What Changed

  • Captured HTTPS_PROXY/HTTP_PROXY/NO_PROXY (and lowercase variants) from the install-time environment and bake them into the generated systemd unit and launchd plist for the managed daemon service, so the background daemon inherits the user's proxy configuration.
  • Added a shared helper in internal/daemon/service.go that collects only set, non-empty proxy variables, leaving service definitions byte-for-byte unchanged when no proxy is configured; wired it into both the systemd and launchd renderers.
  • Added unit tests covering proxy forwarding for both backends plus the unset/empty skip case, and documented the behavior in the troubleshooting and environment reference docs.

Risk Assessment

✅ Low: Small, additive, well-tested change that only augments generated service definitions with proxy env vars; empty-case formatting and the Windows path are correctly handled, with the only note being an intentional install-time capture tradeoff.

Testing

Ran the new and full daemon package tests (all green) and produced product-level evidence by rendering the actual systemd unit and launchd plist the installer writes: with a proxy configured, both definitions gain the proxy Environment/EnvironmentVariables entries (e.g. Environment="HTTPS_PROXY=http://127.0.0.1:7897" and the plist <key>HTTPS_PROXY</key> entries); with no proxy set the output is byte-for-byte identical to the base commit for both backends. This is a non-UI backend change, so the reviewer-visible artifacts are the generated service-definition files rather than screenshots. Throwaway evidence test and temporary base worktree were removed; working tree is clean.

Evidence: systemd unit + launchd plist diffs (no-proxy vs with-proxy)
### systemd diff (no-proxy -> with-proxy)
9a10,12
> Environment="HTTP_PROXY=http://127.0.0.1:7897"
> Environment="HTTPS_PROXY=http://127.0.0.1:7897"
> Environment="NO_PROXY=localhost,127.0.0.1"

### launchd diff (no-proxy -> with-proxy)
22a23,28
> <key>HTTP_PROXY</key>
> <string>http://127.0.0.1:7897</string>
> <key>HTTPS_PROXY</key>
> <string>http://127.0.0.1:7897</string>
> <key>NO_PROXY</key>
> <string>localhost,127.0.0.1</string>

### no-proxy output vs base commit 0e07573: IDENTICAL (systemd + launchd)
Evidence: Generated systemd unit with proxy forwarded
[Unit]
Description=no-mistakes background daemon

[Service]
Type=simple
ExecStart=/usr/local/bin/no-mistakes daemon run --root /home/u/.no-mistakes
WorkingDirectory=/home/u/.no-mistakes
Environment="HOME=/home/u"
Environment="PATH=/home/u/.local/bin:/home/u/go/bin:/home/u/.cargo/bin:/home/u/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin"
Environment="HTTP_PROXY=http://127.0.0.1:7897"
Environment="HTTPS_PROXY=http://127.0.0.1:7897"
Environment="NO_PROXY=localhost,127.0.0.1"
Restart=always
RestartSec=2

[Install]
WantedBy=default.target
Evidence: Generated launchd plist with proxy forwarded
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.kunchenguid.no-mistakes.daemon.deef23b9</string>
  <key>ProgramArguments</key>
  <array>
    <string>/usr/local/bin/no-mistakes</string>
    <string>daemon</string>
    <string>run</string>
    <string>--root</string>
    <string>/home/u/.no-mistakes</string>
  </array>
  <key>WorkingDirectory</key>
  <string>/home/u/.no-mistakes</string>
  <key>EnvironmentVariables</key>
  <dict>
    <key>HOME</key>
    <string>/home/u</string>
    <key>PATH</key>
    <string>/home/u/.local/bin:/home/u/go/bin:/home/u/.cargo/bin:/home/u/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin</string>
    <key>HTTP_PROXY</key>
    <string>http://127.0.0.1:7897</string>
    <key>HTTPS_PROXY</key>
    <string>http://127.0.0.1:7897</string>
    <key>NO_PROXY</key>
    <string>localhost,127.0.0.1</string>
  </dict>
  <key>StandardOutPath</key>
  <string>/home/u/.no-mistakes/logs/daemon.log</string>
  <key>StandardErrorPath</key>
  <string>/home/u/.no-mistakes/logs/daemon.log</string>
  <key>RunAtLoad</key>
  <true/>
  <key>KeepAlive</key>
  <true/>
</dict>
</plist>
Evidence: Baseline systemd unit (no proxy)
[Unit]
Description=no-mistakes background daemon

[Service]
Type=simple
ExecStart=/usr/local/bin/no-mistakes daemon run --root /home/u/.no-mistakes
WorkingDirectory=/home/u/.no-mistakes
Environment="HOME=/home/u"
Environment="PATH=/home/u/.local/bin:/home/u/go/bin:/home/u/.cargo/bin:/home/u/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin"
Restart=always
RestartSec=2

[Install]
WantedBy=default.target
Evidence: Baseline launchd plist (no proxy)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.kunchenguid.no-mistakes.daemon.deef23b9</string>
  <key>ProgramArguments</key>
  <array>
    <string>/usr/local/bin/no-mistakes</string>
    <string>daemon</string>
    <string>run</string>
    <string>--root</string>
    <string>/home/u/.no-mistakes</string>
  </array>
  <key>WorkingDirectory</key>
  <string>/home/u/.no-mistakes</string>
  <key>EnvironmentVariables</key>
  <dict>
    <key>HOME</key>
    <string>/home/u</string>
    <key>PATH</key>
    <string>/home/u/.local/bin:/home/u/go/bin:/home/u/.cargo/bin:/home/u/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin</string>
  </dict>
  <key>StandardOutPath</key>
  <string>/home/u/.no-mistakes/logs/daemon.log</string>
  <key>StandardErrorPath</key>
  <string>/home/u/.no-mistakes/logs/daemon.log</string>
  <key>RunAtLoad</key>
  <true/>
  <key>KeepAlive</key>
  <true/>
</dict>
</plist>

Pipeline

Updates from git push no-mistakes

⏭️ **intent** - skipped

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ℹ️ internal/daemon/service.go:56 - Proxy values are captured from the install-time environment and baked statically into the systemd/launchd service files (user-scoped, 0644). This is intentional and documented, but means (a) a proxy URL with embedded credentials (http://user:pass@host) is written in plaintext to the service definition, and (b) later proxy changes require reinstalling the service to take effect. Both are acceptable tradeoffs given the comments, just noting the new exposure surface.
✅ **Test** - passed

✅ No issues found.

  • go test -race ./internal/daemon/ -run &#39;TestRenderLaunchAgentForwardsProxyEnv|TestServiceProxyEnvSkipsUnsetAndEmpty|TestRenderSystemdUnitForwardsProxyEnv&#39; -v — all pass
  • go test ./internal/daemon/ (full package, no regressions)
  • Rendered real systemd unit + launchd plist with HTTPS_PROXY/HTTP_PROXY/NO_PROXY set and captured the generated definitions as evidence
  • Diffed no-proxy vs with-proxy output for both backends — proxy lines appear only when set
  • Confirmed the 'byte-for-byte unchanged when no proxy' claim by rendering the no-proxy output and diffing against the base commit 0e07573 for both systemd and launchd (IDENTICAL)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@kunchenguid

Copy link
Copy Markdown
Owner

Thanks for this @TheIanLi - the proxy-forwarding fix is genuinely useful and it's properly raised via no-mistakes. One thing before it can merge: it's red on Windows CI, and the failure is in the new proxy-env code. Because Windows env-var names are case-insensitive, serviceProxyEnv() ends up emitting a duplicate HTTPS_PROXY/https_proxy entry, and TestRenderLaunchAgentForwardsEveryProxyEnvKey fails (the launchd forward assertion). Could you dedupe the proxy keys case-insensitively (and re-check the launchd path), then re-run it through no-mistakes? Happy to merge once it's green. 🙏

@TheIanLi TheIanLi force-pushed the fix/forward-proxy-env-to-daemon-service branch 2 times, most recently from 6cc4177 to 66768e3 Compare June 27, 2026 10:59
@TheIanLi

Copy link
Copy Markdown
Contributor Author

Thanks @kunchenguid — addressed and re-run through no-mistakes.

Your ask (Windows dedup): serviceProxyEnv() now de-duplicates the proxy keys case-insensitively on Windows only (keeping the upper-case spelling), so the duplicate HTTPS_PROXY/https_proxy entry is gone. I also reworked TestRenderLaunchAgentForwardsEveryProxyEnvKey to inject the proxy env directly instead of reading os.LookupEnv, so it's deterministic on Windows CI. On case-sensitive platforms both spellings are still forwarded verbatim (the curl-only-lowercase case has its own test).

A few things slightly beyond the original ask — all surfaced by the no-mistakes review, pure correctness/security, no behaviour change:

  • The 0600 service-file write wasn't atomic: re-installing over an existing 0644 file wrote the credential-bearing content first and only Chmod'd to 0600 afterwards — a brief world-readable window. It now writes to a same-dir temp file at the target mode and renames into place (writeFileAtomic). The non-proxy path is unchanged (still a plain 0644 os.WriteFile). The only existing code I touched is the drift-reinstall rollback (restoreOnFailure), which had the same os.WriteFile-doesn't-reset-mode gap now that it can be restoring a 0600 credential file — it routes through the same atomic write.
  • systemd Environment= values weren't escaping %%%, so a percent-encoded proxy credential (e.g. http://user:p%40ss@proxy) would hit systemd specifier expansion — silently corrupting on a known specifier and failing the unit load on systemd ≥249. Now escaped (systemdEnvironmentLine), with a regression test.

Deliberately left out: I did not change the env-less restart behaviour — a daemon start from a shell without the proxy vars still re-renders from the current environment, as before. That touches your existing PATH-refresh tradeoff and felt like your call, not mine.

Green locally (go test -race ./..., gofmt, vet). Ready for your CI approval whenever you have a moment. 🙏

@TheIanLi TheIanLi marked this pull request as draft June 27, 2026 17:51
@TheIanLi TheIanLi force-pushed the fix/forward-proxy-env-to-daemon-service branch from cadce24 to 1a10d94 Compare June 28, 2026 00:08
@TheIanLi

Copy link
Copy Markdown
Contributor Author

Quick follow-up: running the suite on real Windows surfaced that three service-file mode tests — TestWriteServiceFileTightensModeWhenProxyPresent, TestWriteServiceFileReplacesAtomicallyWhenProxyPresent, and TestStartRestoresStaleSystemdUnitAtOriginalModeWhenRefreshInstallFails — assert POSIX modes (0644/0600) that can't hold on Windows: Go reports 0666 for regular files there, and the proxy-bearing service file is only generated on macOS/Linux anyway. I added runtime.GOOS == "windows" skip guards to those three so the Windows CI job stays green; macOS/Linux coverage is unchanged. Re-validated through no-mistakes.

@TheIanLi TheIanLi marked this pull request as ready for review June 28, 2026 00:44
@TheIanLi TheIanLi marked this pull request as draft June 28, 2026 01:08
@TheIanLi TheIanLi force-pushed the fix/forward-proxy-env-to-daemon-service branch 2 times, most recently from ae1a2cd to fff02f6 Compare June 28, 2026 02:08
@TheIanLi

Copy link
Copy Markdown
Contributor Author

Follow-up + a correction to my earlier note, @kunchenguid.

In an earlier comment I said an env-less daemon start "still re-renders from the current environment, as before." That wasn't right, and it glossed over a real regression in drift detection: before this PR the rendered service definition was a pure function of (exe, home, root), so re-rendering was always byte-identical and drift detection was a stable no-op. After the PR, renderSystemdUnit/renderLaunchAgent read the live proxy env — so a daemon start from a shell without the proxy vars exported rendered a no-proxy target, falsely detected drift, reinstalled, and stripped the baked-in proxy, re-introducing the exact 403 Request not allowed this PR set out to fix (plus a needless reinstall+restart churn).

Fixed in the latest commits: the drift render and the install write path now inherit the proxy already baked into the on-disk unit/plist when the current environment has none — mirroring the existing executable inheritance in reinstallManagedServiceIfChanged. Added systemdUnitProxyEnv/launchAgentProxyEnv parsers (next to the existing *Executable ones) that round-trip cleanly through the renderers, including undoing systemd's %%% specifier doubling so a percent-encoded credential reproduces byte-for-byte. writeServiceFile keeps the 0600/atomic-write invariant on inherited writes.

Regression tests cover the systemd and launchd drift no-op (env-less restart no longer strips/churns), the write-path inheritance (e.g. a binary upgrade keeps the proxy + 0600), and the parser round-trips. I also corrected a couple of now-stale doc comments (renderSystemdUnit/renderLaunchAgent became test-only wrappers once drift detection moved to the *WithProxyEnv variants).

So the corrected behavior: an env-less restart now preserves the forwarded proxy instead of dropping it. I deliberately left the systemd EnvironmentFile=-vs-inline question alone — accepted cross-platform tradeoff, since launchd has no equivalent. All green through no-mistakes. 🙏

@TheIanLi TheIanLi marked this pull request as ready for review June 28, 2026 02:14
@TheIanLi

Copy link
Copy Markdown
Contributor Author

Quick heads-up on the red ❌ in the checks panel, @kunchenguid — it's 4 cancelled checks, not failures: docs / build, docs / deploy, Guard generated files, and Require no-mistakes, all "Cancelled after 2s". They were cancelled by workflow concurrency because I pushed a few follow-up commits in quick succession (working around some flaky network on my end), so each new push cancelled the previous run's in-flight light workflows before they reached a verdict.

The substantive CI is green on the current head (fff02f63): CI / check, e2e, and test (ubuntu / macos / windows-latest) all pass. The windows-latest job in particular logs ok internal/daemon, ok internal/gate, and ok internal/pipeline/steps, so nothing here is a real Windows failure.

A re-run should go green on content: the PR only touches docs/*.md and internal/daemon/* (no generated files, so the guard is clean), and the body carries the no-mistakes signature. The head is stable now — no more pushes from me. Could you re-trigger those 4 cancelled checks when you get a moment? Fork PRs need a maintainer to run them. Sorry for the noise. 🙏

TheIanLi and others added 14 commits June 29, 2026 00:15
A daemon started by systemd/launchd inherits only HOME and a curated
PATH, so when the user is behind an HTTP(S) proxy the daemon - and the
agents it spawns (e.g. `claude --print`) - cannot reach the network and
fail with "403 Request not allowed".

Forward HTTP(S)_PROXY / NO_PROXY / ALL_PROXY (both upper- and lower-case
spellings) present at install time into the generated systemd unit and
launchd plist. When no proxy variables are set the generated definition
is byte-for-byte unchanged, so users without a proxy are unaffected.
Windows schtasks already inherits the user's logon environment and is
unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…arded

Addresses a review note: forwarded proxy values are baked into the
generated systemd unit / launchd plist, and a proxy URL can embed
credentials (http://user:pass@host). When any proxy variable is
forwarded, write the service definition owner-only (0600) instead of
0644 so those values are not world-readable. When no proxy is set the
mode stays 0644, keeping non-proxy installs byte-for-byte and
mode-for-mode unchanged. An explicit chmod enforces the mode on
re-install when the file already exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…oolproof

Optimization-only follow-up to the proxy-forwarding change; no behavior change.

- writeServiceFile now takes a render callback and resolves the proxy
  environment itself, exactly once per install, feeding that same value to
  both the renderer and the 0600/0644 mode decision. This drops the duplicate
  serviceProxyEnv() lookup that happened on every install and makes it
  structurally impossible to bake proxy credentials into the content yet write
  it under the world-readable 0644 mode.
- renderLaunchAgent/renderSystemdUnit keep their proxy-resolving signatures for
  standalone callers (drift detection in selfexec.go, tests) and delegate to
  new *WithProxyEnv variants used on the install path.
- Assemble the launchd EnvironmentVariables <dict> in a single strings.Builder
  instead of splicing a proxy fragment via "%s  </dict>"; rendered output is
  byte-for-byte unchanged.
- Add table tests covering every proxyEnvKeys entry (ALL_PROXY, the lower-case
  spellings, NO_PROXY) for both the launchd and systemd renderers.
- Document why the Windows schtasks path forwards no proxy env: an ONLOGON task
  inherits the user's interactive session environment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
serviceProxyEnv() iterated both the upper- and lower-case spellings of
each proxy variable. On Windows, where environment-variable names are
case-insensitive, HTTP_PROXY and http_proxy resolve to the same variable,
so both spellings were baked into the rendered launchd/systemd service
definition as duplicate entries, failing
TestRenderLaunchAgentForwardsEveryProxyEnvKey on Windows CI.

De-duplicate the spellings case-insensitively only on Windows; on
case-sensitive platforms (macOS, Linux) keep forwarding every spelling
verbatim so a value exported only as lower-case http_proxy still reaches
lower-case-only consumers such as curl (which ignores HTTP_PROXY for
plain-HTTP requests to avoid the CGI "httpoxy" issue).

Drive the renderer drift tests off an injected proxyEnv so they no longer
depend on the host's env-var case sensitivity, and add focused
serviceProxyEnv tests for the Windows dedup, the both-spellings case, and
the lower-only regression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Go reports 0666 for regular files on Windows, so the 0644/0600 assertions in the service-file mode tests cannot hold there; the proxy-bearing 0600 service file is only generated on macOS/Linux (managedServiceInstallPath returns "" on Windows). Found by cross-compiling the daemon tests and running them on Windows via WSL interop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drift detection rendered the managed service definition from the live
environment, so a `daemon start` from a shell without the proxy variables
exported rendered a no-proxy target, falsely detected drift, reinstalled,
and stripped the proxy baked in at install time - re-breaking the daemon
with "403 Request not allowed".

The drift render now inherits proxy entries already present in the on-disk
unit/plist when the current environment has none, mirroring the existing
executable inheritance. The install write path does the same so a
legitimate reinstall (e.g. a binary upgrade) preserves the forwarded proxy
and its owner-only 0600 mode instead of dropping it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ance

renderSystemdUnit/renderLaunchAgent are now test-only convenience wrappers:
drift detection switched to the *WithProxyEnv variants, so the comments
claiming they are the drift-detection entry point were stale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kunchenguid kunchenguid merged commit 03f5157 into kunchenguid:main Jun 30, 2026
9 checks passed
@TheIanLi TheIanLi deleted the fix/forward-proxy-env-to-daemon-service branch June 30, 2026 01:49
@kunchenguid

Copy link
Copy Markdown
Owner

Thanks @TheIanLi - merged! Really appreciate the contribution.

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.

2 participants