Skip to content

fix(cli): fail-open safety net — dead proxy never strands the user#112

Merged
secxena merged 4 commits into
stagingfrom
fix/proxy-failopen-watchdog
Jul 4, 2026
Merged

fix(cli): fail-open safety net — dead proxy never strands the user#112
secxena merged 4 commits into
stagingfrom
fix/proxy-failopen-watchdog

Conversation

@secxena

@secxena secxena commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Summary

SOTH installs itself as the machine-wide system proxy, so any way the worker dies without cleanup leaves the OS proxy pointed at a dead 127.0.0.1:<port> — a total internet outage until the user manually runs soth off. This is Tier 0 of the six-agent robustness audit (docs/common/2026-07-04/network-robustness-hardening-plan.md), converting that fail-closed posture to fail-open.

Three layers, all signature-gated so they only ever clear soth's own loopback proxy, never a foreign one (Charles, a corporate proxy, etc.):

1. Health watchdog (audit 0.1) — the structural fix

A reconciler task spawned in supervise_proxy that every 5s checks the local listener. If it's dead for 3 consecutive ticks (~15s, deliberately above a normal rotation window) and the OS proxy still carries soth's signature, it disables the system proxy → connectivity falls back to direct. Re-enables (quietly) once the listener recovers. Aborted via a Drop guard when the supervisor exits, so it can never re-enable a proxy that's being torn down. This catches what the restart loop can't see: a wedged-but-not-exited worker, or a listener that silently stops accepting.

2. Give-up fail-open (audit 0.2)

When the supervisor exhausts its restart budget and bails, it now disables the system proxy first (one-liner, biggest bang-for-buck).

3. Startup crash-repair + doctor detection (audit 0.3)

  • If worker startup fails while a dangling soth proxy from a prior crashed/SIGKILLed session is still active, fail_startup_open disables it before returning the error.
  • soth doctor now reads the live OS proxy setting (os_signature_active), emitting an error-level dangling_system_proxy finding — pointing at soth doctor --reset-network — when the proxy is set but no listener answers. Previously doctor only read soth's own state file and was blind to this.

New surface (system.rs)

  • soth_proxy_signature_active(port) -> bool — cross-platform check of whether the live OS proxy is soth's loopback+port signature (macOS networksetup, Linux gsettings, Windows registry). Best-effort; resolves to false on any read error (safe direction, since true authorizes a disable).
  • enable_quiet(port) — non-printing enable for the watchdog.

Testing

  • 121 soth-cli unit tests pass, including new compute_findings coverage: dead-listener-without-proxy → warn, dead-listener-with-active-proxy → dangling_system_proxy error, healthy → no finding.
  • Windows cfg paths compile+run via the release-ci test-windows lane (soth-cli was added to it in the prior PR).

Scope / follow-ups

This is Tier 0 only (stop total outages). Tiers 1–3 from the plan — corp-proxy/PAC chaining, DNS fail-open to getaddrinfo, bypass-list completeness, captive-portal probing, the data-plane tunnel-idle / connection-cap / h2-permit-leak fixes, and the TLS-backoff rework — are separate follow-up PRs.

🤖 Generated with Claude Code

secxena and others added 2 commits July 4, 2026 13:10
SOTH installs itself as the machine-wide system proxy, so any way the
worker dies without cleanup leaves the OS proxy pointed at a dead
127.0.0.1:<port> — total internet outage until the user manually runs
`soth off`. This converts that fail-CLOSED posture to fail-OPEN (Tier 0
of the 2026-07-04 robustness audit,
docs/common/2026-07-04/network-robustness-hardening-plan.md).

Three layers, all signature-gated so they only ever clear soth's own
loopback proxy, never a foreign one:

1. Health watchdog (0.1): a reconciler task spawned in supervise_proxy
   that every 5s checks the local listener. If it's dead for 3
   consecutive ticks (~15s, above a normal rotation window) AND the OS
   proxy still carries soth's signature, it disables the system proxy so
   connectivity falls back to direct. Re-enables (quietly) once the
   listener recovers. Aborted via Drop guard when the supervisor exits,
   so it can never re-enable a proxy being torn down. This covers the
   cases the restart loop can't see — a wedged-but-not-exited worker, a
   listener that stops accepting.

2. Give-up fail-open (0.2): when the supervisor exhausts its restart
   budget and bails, it now disables the system proxy first.

3. Startup crash-repair (0.3): if worker startup fails while a dangling
   soth proxy from a prior crashed/SIGKILLed session is still active,
   disable it before returning the error (fail_startup_open). And
   `soth doctor` now reads the *live* OS proxy setting (new
   os_signature_active), emitting an error-level `dangling_system_proxy`
   finding — pointing at `soth doctor --reset-network` — when the proxy
   is set but no listener answers. Previously doctor only read soth's
   own state file and couldn't see this at all.

New system.rs surface: soth_proxy_signature_active(port) (cross-platform
signature check) and enable_quiet(). 121 soth-cli unit tests pass incl.
new doctor findings coverage; the Windows cfg paths are exercised by the
release-ci test-windows lane.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-applies a fix that was orphaned off PR #111 before its merge (the
env-aware home_dir landed on the branch after the merge commit, so
staging never got it). dirs::home_dir() resolves via the known-folder
OS API on Windows and ignores the environment, so `~` expansion
(default bundle_dir, config/device-id paths, bootstrap init dir)
escaped both HOME and SOTH_HOME_DIR — the recurring
start_forwards_allow_daemon_child_fallback_flag failure on the native
Windows lane ("Bundle directory C:\Users\runneradmin\... missing").

Add an env-aware home_dir() that prefers a non-empty $HOME and falls
back to dirs::home_dir(); route cli_config's path helpers and the
command-graph bootstrap dir through it. Also fixes home-relocation via
$HOME on Windows in the product, not just tests. Unchanged where HOME
is unset (normal Windows) or equals the profile dir (Unix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
secxena and others added 2 commits July 4, 2026 17:25
Addresses adversarial-review finding 1 on this PR: fail_startup_open
disabled the OS proxy whenever it carried soth's signature, without
checking the listener was actually dead. On a Windows bind-conflict — a
second 'soth start' whose worker fails to bind because a HEALTHY prior
instance already holds the port — this tore down the running instance's
system proxy, silently turning off interception with nothing to
re-enable it.

Add !is_local_listener_ready(port) to the guard so we only clear a
soth-signature proxy when nothing is answering (the genuine
dangling-from-a-crash case), mirroring the watchdog's dead-AND-signature
rule. A bind conflict against a healthy instance now leaves that
instance's proxy untouched.

121 soth-cli tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ng probes

Addresses the deeper adversarial-review findings on this PR:

1. (loses-internet) The Windows signature used starts_with — a PREFIX
   match — so a foreign loopback proxy whose port has soth's as a
   numeric prefix (soth :1080 vs foreign :10800; soth :8 vs :8080)
   falsely matched soth's signature, letting the watchdog / disable path
   clear someone else's proxy. Replaced with exact host:port token
   matching that also handles the scheme=host:port;... list form
   (windows_proxy_value_is_soth), and fixed the same pre-existing bug in
   the sentinel-missing disable path. macOS/Linux already matched exactly.

2. (misconfigured) The watchdog re-enabled the proxy after a user ran
   → Removing system proxy configuration... during a watchdog-induced outage — silently undoing the
   user's intent, since the in-process disabled_by_watchdog flag can't
   observe an external → Removing system proxy configuration.... Added a user_proxy_off sentinel set by
   the user-facing disable (not the watchdog's own quiet disable) and
   cleared by any enable; the watchdog checks it before re-enabling and
   stands down if the user turned it off.

3. (runtime) The watchdog's synchronous probes (500ms connect;
   subprocess reads for the signature) ran directly on the async
   workers. Wrapped both in spawn_blocking so a per-tick block can't
   starve the supervisor on a small runtime; a probe panic resolves to
   the safe direction (not-ready / not-ours).

New tests: exact-match truth table (Windows-gated, run by the CI
Windows lane) and the sentinel round-trip. 121 soth-cli tests pass.

Known narrow limitations left as-is (documented for reviewers): a
co-located instance B can still disable instance A's proxy if B starts
precisely during A's multi-second restart gap (cross-process, would need
IPC); and a shutdown at the exact instant of a watchdog re-enable can
race (very narrow interleave).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@secxena
secxena merged commit 3128e65 into staging Jul 4, 2026
7 checks passed
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