Skip to content

feat: see the fleet the way providers do — by IP, not by machine (CashPilot-5qc) - #166

Merged
GeiserX merged 7 commits into
mainfrom
feat/egress-conflicts
Aug 2, 2026
Merged

feat: see the fleet the way providers do — by IP, not by machine (CashPilot-5qc)#166
GeiserX merged 7 commits into
mainfrom
feat/egress-conflicts

Conversation

@GeiserX

@GeiserX GeiserX commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Every bandwidth provider caps per IP address, not per device. Honeygain treats a second active device on a network as "network overused"; EarnApp documents that extra devices behind one IP share a single daily cap.

CashPilot's whole fleet model encourages deploying a service to several machines, and warned about none of it. Two workers in one house are two rows on the dashboard and one customer to the provider — so the second earns nothing.

Seeing this requires knowing about the other machines, which is why a single-host tool structurally cannot do it.

What lands

  • Each worker reports its public egress IP and, where it can tell locally, whether it is a hosted machine.
  • Deploying to a worker that shares an address with one already running the service is called out by name, before the deploy.
  • GET /api/fleet/egress-groups groups the fleet by exit rather than by host.
  • A residential-only service on a machine that identifies itself as a VPS is no longer a polite "check this yourself" — it's a verdict.

Three rules that keep the warning from being wrong

A warning that cries wolf gets switched off, so:

  • An undetected IP is neither shared nor distinct. Those workers produce no finding at all and are bucketed as undetermined.
  • A private address is a detection failure, not an identity. Concretely: every worker in the reference fleet has a 100.64/10 tailnet address — grouping on it would collapse the entire fleet into one fabricated conflict.
  • An absent devices_per_ip means undocumented, not unlimited. 0 is a verified no-limit; a missing key is not. Only 4 of 50 services declare one.

Detection is local-first: the hosting hint comes from DMI vendor strings, so nothing is disclosed to a third party and it works offline. A bare hypervisor (QEMU, VMware) is deliberately not hosting — a VM on a home server is a residential connection.

Two defects fixed, one confirmed live in 1.7.0

  • /api/services/{slug}/preflight?worker_id=N returned 500. Confirmed against the deployed UI on real worker rows: AttributeError: 'str' object has no attribute 'get'. list_workers returns JSON text columns; the endpoint treated them as dicts.
  • /api/services/{slug}/producer-state never matched a container — it filtered on c["service"] while heartbeats emit slug, so every service reported UNKNOWN. Merged after 1.7.0, so it never reached production.

Both had passing tests, because both fixtures hand-fed a shape production never produces. Tests now build rows the way SQLite actually returns them.

Review

An independent review raised 22 findings; each was reproduced by execution before being acted on. The serious ones: a stalled IP lookup could stall the serial heartbeat loop and take a worker offline (httpx's timeout is per-operation, not a deadline), and not_checked claimed the connection type was checked while a finding beside it said it couldn't be. Also fixed: an off-by-one that under-warned, Android workers invisible to the check, a custom IP endpoint silently falling back to third parties, and a unit test making a real network request.

Verification

  • ruff check . + ruff format --check . clean
  • pytest --cov=app --cov-fail-under=901704 passed, coverage 94.60%
  • Verified against real hardware: both home servers report ASUSTeK COMPUTER INC. and correctly classify as not hosting; the DMI path is readable from inside the worker container; the worker image module set was simulated to prove app/egress.py adds no import the worker lacks.

Detection is on by default and makes one outbound call per hour, so it is documented in the README (not just CLAUDE.md) with CASHPILOT_EGRESS_DETECT=off and two ways to avoid the third party entirely.

Summary by CodeRabbit

  • New Features
    • Added fleet-wide egress IP grouping and a new endpoint for viewing shared, unknown, and worker-specific egress details.
    • Worker heartbeats now report egress IP and network type.
    • Added configuration for network classification, IP detection, explicit egress addresses, and custom lookup endpoints.
  • Bug Fixes
    • Preflight checks now detect shared egress conflicts, per-IP limits, duplicate instances, and hosted-network mismatches.
    • Improved worker and service-state handling, including legacy container data compatibility.
    • Heartbeats recover from temporary detection or network failures.
  • Documentation
    • Documented egress tracking, privacy controls, fleet grouping, limits, and configuration options.

GeiserX added 6 commits August 2, 2026 17:41
…hPilot-5qc)

Every bandwidth provider caps per IP address, not per device. Honeygain
treats a second active device on a network as "network overused"; EarnApp
documents that extra devices behind one IP share one daily cap. CashPilot's
whole fleet model encourages deploying a service to several machines, and
warned about none of this: two workers in one house are two rows to us and
one customer to the provider, so the second earns nothing.

Seeing this requires knowing about the OTHER machines, which is why a
single-host tool structurally cannot do it.

Each worker now reports its public egress IP and, where it can tell, whether
it is a hosted machine. Deploying to a worker that shares an address with one
already running the service is called out by name, the fleet can be grouped by
exit rather than by host (/api/fleet/egress-groups), and a residential-only
service on a machine that identifies itself as a VPS is no longer a polite
"check this yourself" but a verdict.

Three rules keep the warning from being wrong, because a warning that cries
wolf gets ignored and would be worse than nothing:

* An undetected IP is neither shared nor distinct. Those workers go to a
  bucket that says exactly that, and produce no finding at all.
* A private address is a detection failure, not an identity. This matters
  concretely: every worker in the reference fleet has a 100.64/10 tailnet
  address, and grouping on it would collapse the entire fleet into one
  fabricated conflict.
* An absent devices_per_ip means nobody documented it — not "unlimited". The
  schema uses 0 for a verified no-limit, which is a real answer; a missing key
  is not, and only 4 of 50 services declare one today.

Detection is local first: the hosting hint comes from DMI vendor strings, so
nothing is disclosed to a third party and it works offline. A bare hypervisor
(QEMU, VMware) is deliberately NOT hosting — a VM on a home server is a
residential connection and the most common deployment there is. The IP lookup
is the one outbound call made purely to learn about the user, so it is
opt-outable and endpoint-overridable, and every failure returns None rather
than a guess.

Fixes two defects found while wiring this up, both live in 1.7.0:

* /api/services/{slug}/preflight?worker_id=N returned 500. list_workers and
  get_worker hand back raw rows, so containers/system_info arrive as JSON
  TEXT, and the endpoint passed a str to code expecting a mapping. Every
  caller that reads inside them now goes through _decoded_worker.
* /api/services/{slug}/producer-state never matched a container. It filtered
  on c["service"] while heartbeats emit "slug", so container_running was
  always false and every service reported "unknown" — the feature was inert.

Both had passing tests, because both fixtures hand-fed a shape production
never produces. The tests now build worker rows the way SQLite actually
returns them, and container_slug accepts either key so neither shape can
silently match zero containers again.

Verification: ruff clean, 1654 passed, coverage 94.47%.
An independent read-only review of this branch raised 22 findings. Every one
acted on below was reproduced by execution first rather than taken on trust;
the two rated HIGH were both real.

Correctness:

* not_checked dropped "egress IP type" when the ADDRESS was known, but the
  label is about the connection TYPE. A worker with an IP and no type produced
  one response that said the type was checked and, beside it, a finding saying
  it could not be. Gated on the type now. The test that asserted the old
  behaviour asserted the bug, so it is replaced by two that pin both halves.
* The instance already running on the target machine was never counted against
  a documented devices_per_ip > 1, so a third instance under a limit of two
  read as "reduced" instead of "will earn nothing" — under-warning in exactly
  the situation the feature exists for.
* assess() read the connection type from the system_info kwarg while the fleet
  half read worker["system_info"], so the same worker produced different
  verdicts depending on whether the caller passed a redundant argument. One
  fact, one source: system_info now defaults from the worker.
* Android workers report `apps` with a boolean `running`, not containers. A
  phone on the home WiFi beside a server is two devices on ONE public IP — the
  canonical case here — and it was invisible.
* Self-exclusion keyed on client_id, but `None != None` is False, so two legacy
  rows with a NULL client_id cancelled each other and hid a real conflict. Keys
  on the primary key now.

Robustness of the heartbeat, which is the one thing here that could hurt:

* httpx's timeout is per-operation, not a deadline — its read timeout is the
  maximum gap between chunks — so an endpoint dribbling bytes could hold the
  request open indefinitely. The heartbeat loop is serial, so that stops
  heartbeats, the UI marks the worker offline after 180s, and deploys for that
  host start failing. A diagnostic must never be able to take the control plane
  down: the whole attempt is now bounded by one wall-clock budget.
* The response body was read unbounded before being truncated to 64 chars. It
  is capped while streaming now.
* Failures are cached briefly, so a blackholed network costs the timeout once
  every few minutes rather than on every single heartbeat.
* The cache stamped time from the event loop's clock, whose epoch is
  unspecified; a loop starting near zero makes the age negative, which is
  always under the TTL, pinning a stale address forever. time.monotonic().

Honesty, which is the whole point of this feature:

* A custom CASHPILOT_EGRESS_IP_URL fell back to the public endpoints on any
  failure — quietly undoing the one choice an operator makes specifically to
  avoid disclosing to a third party. It is used exclusively now, and a test
  asserts nothing else is ever contacted.
* An invalid CASHPILOT_EGRESS_IP was discarded in total silence. "192.168.1.5"
  is what most people would call their IP, and the only symptom was that
  nothing happened. It warns and falls back to the lookup.
* "microsoft corporation" is removed from the hosting hints: Surface hardware
  and Hyper-V guests report it too, including Hyper-V on a home Windows
  desktop, and a false hosting verdict fires a ban warning at a user who is
  fine — precisely what classify_vendor's own docstring says to avoid.
* IPv4-mapped addresses are unwrapped rather than returned verbatim, so a host
  reported as both 81.61.1.9 and ::ffff:81.61.1.9 matches itself; this also
  makes the private-address check correct independently of the interpreter
  patch version. NAT64 and 6to4 are rejected outright — both can carry a
  private IPv4 past an is_global check.
* The schema and CLAUDE.md claimed devices_per_ip: 0 SUPPRESSES the conflict
  warning. It downgrades it to a shared-bandwidth note, because two instances
  share one connection whatever the provider permits. A contributor who wrote 0
  expecting silence would have deleted the key instead, which is the worse
  state. Docs corrected to match the code.
* The IPv6 limitation is now stated in the module and the README instead of
  being left for a user to discover: grouping is exact-address equality, so a
  native-IPv6 line is never matched. It can miss a conflict; it cannot invent
  one, which is the right way round.

Also: the new env vars are documented in the README rather than only in the
agent-facing CLAUDE.md — detection is on by default and makes an outbound call,
so a user who wants it off has to be able to find the switch. And a unit test
was making a REAL request to api.ipify.org; the worker tests now run against a
fake that fails loudly if anything tries to reach the network.

Verification: ruff clean, 1671 passed, coverage 94.59%.
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Workers now report egress IP and network type. The server groups workers by egress, applies fleet-aware preflight findings, exposes egress groups through an API, and matches producer containers by normalized service slug. Documentation and tests cover configuration, limits, address handling, and heartbeat recovery.

Changes

Fleet egress awareness

Layer / File(s) Summary
Worker egress detection and grouping
app/egress.py, app/worker_api.py, Dockerfile.worker, tests/test_egress.py, README.md, CLAUDE.md, docs/fleet.md
Workers detect and classify public egress addresses. The server groups shared, unknown, and undetected egress values. Heartbeats include egress metadata and recover after cycle failures.
Fleet-aware preflight and API integration
app/main.py, app/preflight.py, tests/test_egress.py, tests/test_preflight.py, services/_schema.yml, docs/fleet.md, docs/AUTOPILOT-WORKLOG.md
Worker JSON fields are decoded before assessment. Preflight evaluates shared egress, service limits, hosted networks, and online peers. A fleet egress-groups endpoint reports grouped worker data.
Normalized producer service matching
app/main.py, tests/test_producer_state.py
Producer-state matching uses normalized container slugs and retains legacy service compatibility.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • GeiserX/CashPilot#14: Related Android app status handling supports the worker service extraction logic.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 24.07% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: grouping and viewing the fleet by public egress IP instead of machine.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/egress-conflicts

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.

@GeiserX

GeiserX commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@codecov

codecov Bot commented Aug 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.09544% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.63%. Comparing base (46eede4) to head (53641e0).

Files with missing lines Patch % Lines
app/egress.py 94.33% 6 Missing ⚠️
app/worker_api.py 98.63% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #166      +/-   ##
==========================================
+ Coverage   94.45%   94.63%   +0.18%     
==========================================
  Files          39       40       +1     
  Lines        4775     4999     +224     
==========================================
+ Hits         4510     4731     +221     
- Misses        265      268       +3     
Files with missing lines Coverage Δ
app/main.py 96.79% <100.00%> (+0.02%) ⬆️
app/preflight.py 100.00% <100.00%> (ø)
app/worker_api.py 90.20% <98.63%> (+2.67%) ⬆️
app/egress.py 94.33% <94.33%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

… the heartbeat (CashPilot-5qc)

A second review, this time of the fixes from the first round, found three HIGH
issues. All three were reproduced by execution before being changed, and two of
them were introduced or worsened by that first round.

**The instance count under-reported, on the line above the fix that added it.**
The cosmetic set() dedup of peer NAMES fed the arithmetic, so two distinct
machines sharing a display name counted as one: three instances against a limit
of two produced "reduced" instead of "will earn nothing". WORKER_NAME defaults
to the hostname, so two Raspberry Pis, two identical NAS images, or one compose
file copy-pasted twice all hit this. The set is display-only now and the count
uses the machines.

**A retired machine fabricated conflicts against a live one.** list_workers has
no status filter, and the stale-worker purge deliberately spares ENROLLED rows —
so a worker that is merely switched off keeps its row forever, carrying a last
heartbeat in which every container is still "running" and its last-known egress
IP. Switch off one of two machines, deploy honeygain on the survivor a month
later, and the preflight said it was already running on the dead one and (with
the new arithmetic) escalated that to will_earn_nothing. This module promises
the opposite failure direction in its own docstring: a missed conflict, never an
invented one. Peers are now the online workers; the worker being deployed to is
still looked up from the full set, so one that just restarted can be assessed
rather than 404ing.

**The heartbeat could still be killed permanently.** The previous round bounded
the egress lookup, but _send_heartbeat builds its payload OUTSIDE its own try —
docker_available, the egress lookup and the network probe all run while the dict
literal is evaluated — and _heartbeat_loop had no guard at all, so any exception
there ended the task silently and forever. That is strictly worse than the stall
it replaced: a missed cycle costs one 180s offline window and self-heals; a dead
task means offline until someone restarts the container, while the service
containers keep earning and nothing surfaces it. Every cycle is guarded now,
with CancelledError re-raised so shutdown still works.

Also, from the same review:

* public_ip accepted the deprecated IPv4-compatible form, so ::192.168.1.5 was
  reported global and a LAN address could have become a grouping key. It also
  broke self-matching: a host seen as 81.61.1.9 and as ::81.61.1.9 produced two
  keys. Both forms unwrap now, and fec0::/10 — site-local, which Python does not
  call private — joins the reject list.
* The Android fix was unreachable. running_slugs returned early when
  `containers` was absent or malformed, before `apps` was ever read, so the
  guarantee only held because a different module injects that key. It falls
  through now, and the test covers a worker with apps and no containers.
* not_checked gated on the worker while the residential finding read the
  system_info kwarg — the same two-source divergence the previous commit claimed
  to have removed, mirrored. One source now, and both use normalise_network_type
  rather than an inline string compare.
* The lookup budget was named "total" but applied per endpoint, so three
  endpoints meant 30s plus the 15s POST on a 60s serial cycle, and a longer list
  would have breached the 180s offline threshold. It is one deadline shared
  across every endpoint now.
* The response cap was applied AFTER appending, and aiter_bytes yields
  DECOMPRESSED bytes, so a gzipped reply could allocate megabytes inside a
  single chunk. Truncated before appending.
* _same() returned False for everything when a worker had neither id nor
  client_id, making it its own peer. The comment justifying the primary-key
  switch also cited a NULL client_id, which the NOT NULL schema makes
  impossible; it now states the real reason.

Verification: ruff clean, 1716 passed, coverage 94.64%.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (3)
tests/test_egress.py (3)

385-405: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The sentinel does not fail loudly.

_detect_egress_ip catches Exception around every lookup attempt. The default _FakeStream(exc=AssertionError(...)) therefore never surfaces; the lookup logs at debug level and returns None. Only the tests that assert on seen detect an unexpected request. Raise a BaseException subclass instead, so the sentinel escapes the blanket handler.

♻️ Proposed change
+class _NetworkAccessAttempted(BaseException):
+    """Escapes `except Exception` in the code under test."""
+
+
 `@pytest.fixture`
 def no_network(monkeypatch):
     """Fail loudly if anything here would really reach the internet."""
@@
-    install(_FakeStream(exc=AssertionError("a test tried to reach the network")))
+    install(_FakeStream(exc=_NetworkAccessAttempted("a test tried to reach the network")))
     return install, seen
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_egress.py` around lines 385 - 405, Update the default _FakeStream
exception configured by the no_network fixture to use a BaseException subclass
rather than AssertionError, so _detect_egress_ip’s Exception handler cannot
suppress unexpected network access. Preserve the existing message and ensure the
sentinel escapes the lookup handler and fails the test immediately.

555-577: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

TestEndpoints._call and TestARetiredMachineMustNotFabricateAConflict._preflight are the same harness.

Both build JSON-TEXT worker rows and patch the same four targets. Extract one module-level helper and call it from both classes. This keeps the row-shape fixture — the thing the regression depends on — in a single place.

Also applies to: 634-655

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_egress.py` around lines 555 - 577, The test harness setup is
duplicated between TestEndpoints._call and
TestARetiredMachineMustNotFabricateAConflict._preflight. Extract the shared
JSON-text worker-row construction and four target patches into one module-level
helper, then have both methods invoke that helper while preserving their
existing callback arguments and async execution behavior.

496-502: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The oversized-body test does not exercise the streaming cap.

_FakeStream.aiter_bytes yields the whole body in one chunk, so _fetch_egress_ip accumulates all 10 MB before it checks len(body) >= _EGRESS_MAX_BYTES. The assertion still passes, but the memory bound the test names is not verified. Make the double yield small chunks so the break is the reason the read stops.

♻️ Proposed change
     async def aiter_bytes(self):
-        yield self.body
+        for i in range(0, len(self.body) or 1, 64):
+            yield self.body[i : i + 64]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_egress.py` around lines 496 - 502, Update
test_an_oversized_body_cannot_exhaust_memory and its _FakeStream input so the
oversized payload is yielded in multiple small chunks rather than one 10 MB
chunk. Ensure the stream produces enough data to cross _EGRESS_MAX_BYTES,
allowing _fetch_egress_ip to stop via its streaming cap and making the test
verify bounded reading.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/egress.py`:
- Around line 62-73: Update HOSTING_VENDOR_HINTS to replace the broad "oracle"
substring with a host-specific hint such as "oracle cloud", preventing Oracle
Corporation VirtualBox DMI values from being classified as hosting. Extend
test_a_home_lab_hypervisor_is_not_hosting to cover the VirtualBox case and
verify it remains non-hosting.

In `@app/preflight.py`:
- Around line 132-133: Update the residential check in the preflight requirement
evaluation to treat a missing reqs["vps_ip"] value as false, matching the schema
default. Resolve that default before the vps_ip comparison so entries with
residential_ip enabled and no vps_ip still follow the existing hosted-worker
EARNS_NOTHING and unverified-worker CHECK_YOURSELF outcomes.

In `@tests/test_egress.py`:
- Around line 724-730: Update the test around the heartbeat task created by
worker_api._heartbeat_loop() to await the task after calling task.cancel(), and
suppress the expected CancelledError before the test exits. Preserve the
existing call-count assertion and cancellation behavior.

---

Nitpick comments:
In `@tests/test_egress.py`:
- Around line 385-405: Update the default _FakeStream exception configured by
the no_network fixture to use a BaseException subclass rather than
AssertionError, so _detect_egress_ip’s Exception handler cannot suppress
unexpected network access. Preserve the existing message and ensure the sentinel
escapes the lookup handler and fails the test immediately.
- Around line 555-577: The test harness setup is duplicated between
TestEndpoints._call and TestARetiredMachineMustNotFabricateAConflict._preflight.
Extract the shared JSON-text worker-row construction and four target patches
into one module-level helper, then have both methods invoke that helper while
preserving their existing callback arguments and async execution behavior.
- Around line 496-502: Update test_an_oversized_body_cannot_exhaust_memory and
its _FakeStream input so the oversized payload is yielded in multiple small
chunks rather than one 10 MB chunk. Ensure the stream produces enough data to
cross _EGRESS_MAX_BYTES, allowing _fetch_egress_ip to stop via its streaming cap
and making the test verify bounded reading.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ecad71bc-a5ab-4d29-87e7-a78f2caf278c

📥 Commits

Reviewing files that changed from the base of the PR and between 46eede4 and 53641e0.

📒 Files selected for processing (13)
  • CLAUDE.md
  • Dockerfile.worker
  • README.md
  • app/egress.py
  • app/main.py
  • app/preflight.py
  • app/worker_api.py
  • docs/AUTOPILOT-WORKLOG.md
  • docs/fleet.md
  • services/_schema.yml
  • tests/test_egress.py
  • tests/test_preflight.py
  • tests/test_producer_state.py

Comment thread app/egress.py
Comment on lines +62 to +73
HOSTING_VENDOR_HINTS = (
"amazon ec2",
"digitalocean",
"google compute engine",
"hetzner",
"linode",
"openstack",
"oracle",
"ovh",
"scaleway",
"vultr",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether any repo doc/test asserts Oracle Cloud DMI strings, and how VirtualBox is treated.
rg -n -i -C3 'oracle|virtualbox|innotek' --glob '!**/.git/**'

Repository: GeiserX/CashPilot

Length of output: 155


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate files =="
git ls-files | rg '(^|/)egress\.py$|(^|/)preflight|test' | head -200

echo "== app/egress.py outline =="
ast-grep outline app/egress.py --view expanded || true

echo "== app/egress.py lines 1-180 =="
cat -n app/egress.py | sed -n '1,180p'

echo "== search for HOSTING_VENDOR_HINTS and container_hint helpers =="
rg -n -C6 'HOSTING_VENDOR_HINTS|hosting|DMI|DMI|sys_vendor|product_name|container_slug|preflight' --glob '*.py' .

Repository: GeiserX/CashPilot

Length of output: 50373


🌐 Web query:

VirtualBox DMI sys_vendor product_name Oracle Corporation system vendor

💡 Result:

In Oracle VM VirtualBox, the DMI (Desktop Management Interface) system information, including fields like System Vendor and Product Name, is provided by the virtual machine's emulated BIOS/UEFI firmware to the guest operating system [1][2]. By default, these fields typically report Oracle Corporation as the manufacturer and VirtualBox as the product [3]. You can modify these and other DMI values for a specific virtual machine using the VBoxManage command-line tool [1][2]. Commands for Modifying DMI Information To change DMI information, use the following syntax for a VM [1][2]: VBoxManage setextradata "VM-name" "VBoxInternal/Devices/pcbios/0/Config/DmiSystemVendor" "Your Vendor" VBoxManage setextradata "VM-name" "VBoxInternal/Devices/pcbios/0/Config/DmiSystemProduct" "Your Product" Important Considerations: 1. Firmware Type: If your VM is configured to use EFI firmware instead of the legacy BIOS (pcbios), you must replace pcbios with efi in the configuration key path (e.g., VBoxInternal/Devices/efi/0/Config/DmiSystemVendor) [2][4]. 2. String Prefixing: Some DMI parameters are expected to be strings. If you provide a value that looks like a number, the VM may fail to start with a VERR_CFGM_NOT_STRING error [2][4]. In such cases, prepend the value with "String:" (case-sensitive, e.g., "String:1234") to force it to be treated as a string [2][5]. 3. Resetting Values: To reset a field to its default VirtualBox value, remove the custom setting by running the VBoxManage setextradata command for that key without providing a value [6]. 4. Purpose: These modifications are often used to prevent guest operating systems (particularly Windows) from detecting that they are running in a virtual machine, which can help avoid reactivation requests or allow software that checks for specific hardware manufacturers to run [1][2][7]. You can verify the current DMI information within the guest system using tools such as dmidecode on Linux [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
from pathlib import Path

app = Path("app/egress.py")
tree = ast.parse(app.read_text())
hints = next(
    (node.value.values[0].elts for node in ast.walk(tree)
     if isinstance(node, ast.Assign) and any(a.id == "HOSTING_VENDOR_HINTS" for a in node.targets)),
    None,
)
if hints is None:
    raise SystemExit("HOSTING_VENDOR_HINTS not found")
hints = [s.value for s in hints]
tests = Path("tests/test_egress.py").read_text()
print("hints=", hints)
print("oracle_hint=", "oracle")
print("contains_oracle_hint=", "oracle" in hints)
print("contains_oracle_cloud_hint=", "oracle cloud" in hints)
for vendor in ["Oracle Corporation", "Oracle VM VirtualBox", "VirtualBox", "Innotek GmbH"]:
    print(vendor, "===>", any(h in vendor.lower() for h in hints))
print("oracle_nonhosting_test_present=", '"Oracle Corporation"' in tests or "'Oracle Corporation'" in tests)
print("oracle_virtualbox_test_present=", (
    ''.join(['"Oracle VM VirtualBox"' in tests, ''.join(["'Oracle VM VirtualBox'" in tests])))
)
PY

Repository: GeiserX/CashPilot

Length of output: 446


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
from pathlib import Path

app = Path("app/egress.py")
tree = ast.parse(app.read_text())
hints = next(
    (node.value.values[0].elts for node in ast.walk(tree)
     if isinstance(node, ast.Assign) and any(a.id == "HOSTING_VENDOR_HINTS" for a in node.targets)),
    None,
)
if hints is None:
    raise SystemExit("HOSTING_VENDOR_HINTS not found")
hints = [s.value for s in hints]
tests = Path("tests/test_egress.py").read_text()
print("hints=", hints)
print("oracle_hint=", "oracle")
print("contains_oracle_hint=", "oracle" in hints)
print("contains_oracle_cloud_hint=", "oracle cloud" in hints)
for vendor in ["Oracle Corporation", "Oracle VM VirtualBox", "VirtualBox", "Innotek GmbH"]:
    print(vendor, "===>", any(h in vendor.lower() for h in hints))
print("oracle_nonhosting_test_present=", '"Oracle Corporation"' in tests or "'Oracle Corporation'" in tests)
print("oracle_virtualbox_test_present =", '"Oracle VM VirtualBox"' in tests or "'Oracle VM VirtualBox'" in tests)
PY

Repository: GeiserX/CashPilot

Length of output: 324


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
from pathlib import Path

app = Path("app/egress.py")
tree = ast.parse(app.read_text())

node = next(
    node
    for node in ast.walk(tree)
    if isinstance(node, ast.Assign) and any(isinstance(a, ast.Name) and a.id == "HOSTING_VENDOR_HINTS" for a in node.targets)
)
hints = [elt.value for elt in node.value.elts]
tests = Path("tests/test_egress.py").read_text()

print("hints=", hints)
print("contains_oracle_hint=", "oracle" in hints)
print("contains_oracle_cloud_hint=", "oracle cloud" in hints)
for vendor in ["Oracle Corporation", "Oracle VM VirtualBox", "VirtualBox", "Innotek GmbH"]:
    print(vendor, "===>", any(h in vendor.lower() for h in hints))
print("oracle_nonhosting_test_present=", '"Oracle Corporation"' in tests or "'Oracle Corporation'" in tests)
print("oracle_virtualbox_test_present =", '"Oracle VM VirtualBox"' in tests or "'Oracle VM VirtualBox'" in tests)
print("oracle_cloud_hosting_test_present =", '"Oracle Cloud"', "'Oracle Cloud'" in tests)
PY

Repository: GeiserX/CashPilot

Length of output: 591


Narrow the Oracle hint to avoid flagging VirtualBox guests.

HOSTING_VENDOR_HINTS includes "oracle", and classify_vendor() matches hints by substring. Oracle VM VirtualBox DMI reports Oracle Corporation, so that DMI returns hosting and preflight.assess() emits the residential-only ban warning. Use a host-specific string like "oracle cloud" and add the VirtualBox case to test_a_home_lab_hypervisor_is_not_hosting.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/egress.py` around lines 62 - 73, Update HOSTING_VENDOR_HINTS to replace
the broad "oracle" substring with a host-specific hint such as "oracle cloud",
preventing Oracle Corporation VirtualBox DMI values from being classified as
hosting. Extend test_a_home_lab_hypervisor_is_not_hosting to cover the
VirtualBox case and verify it remains non-hosting.

Comment thread app/preflight.py
Comment on lines 132 to +133
if reqs.get("residential_ip") and reqs.get("vps_ip") is False:
findings.append(
{
"verdict": CHECK_YOURSELF,
"message": (
"This needs a residential IP. On a VPS or datacentre connection it typically "
"earns far less, or the account is banned outright. CashPilot cannot check "
"your connection type, so this one is on you."
),
}
)
verdicts.append(CHECK_YOURSELF)
if egress.normalise_network_type(info.get("egress_network_type")) == egress.HOSTING:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# How many catalog entries declare residential_ip without vps_ip?
fd -e yml -e yaml . services --exec sh -c '
  if grep -q "residential_ip:[[:space:]]*true" "$1" && ! grep -q "vps_ip:" "$1"; then
    echo "MISSING vps_ip: $1"
  fi
' _ {}

Repository: GeiserX/CashPilot

Length of output: 1300


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== preflight outline =="
ast-grep outline app/preflight.py --match Preflight --view expanded || true

echo
echo "== preflight relevant section =="
sed -n '1,230p' app/preflight.py | cat -n

echo
echo "== schema around vps/residential =="
fd -e yml -e yaml _schema.yml services | xargs -r cat -n | sed -n '70,105p'

echo
echo "== current diff/stat =="
git diff --stat
git diff -- app/preflight.py -- services/_schema.yml | sed -n '1,260p'

Repository: GeiserX/CashPilot

Length of output: 14373


Apply the vps_ip schema default in the residential check.

residential_ip: true with no vps_ip means vps_ip: false, but reqs.get("vps_ip") is False skips missing keys. Entries omitting vps_ip currently produce no residential IP finding, so hosted workers lose the EARNS_NOTHING verdict and unverified workers lose the CHECK_YOURSELF note. Resolve the default before reading it.

🐛 Proposed fix
-    if reqs.get("residential_ip") and reqs.get("vps_ip") is False:
+    residential_required = bool(reqs.get("residential_ip"))
+    # Schema: vps_ip defaults to the opposite of residential_ip.
+    vps_allowed = reqs.get("vps_ip", not residential_required)
+    if residential_required and vps_allowed is False:
📝 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
if reqs.get("residential_ip") and reqs.get("vps_ip") is False:
findings.append(
{
"verdict": CHECK_YOURSELF,
"message": (
"This needs a residential IP. On a VPS or datacentre connection it typically "
"earns far less, or the account is banned outright. CashPilot cannot check "
"your connection type, so this one is on you."
),
}
)
verdicts.append(CHECK_YOURSELF)
if egress.normalise_network_type(info.get("egress_network_type")) == egress.HOSTING:
residential_required = bool(reqs.get("residential_ip"))
# Schema: vps_ip defaults to the opposite of residential_ip.
vps_allowed = reqs.get("vps_ip", not residential_required)
if residential_required and vps_allowed is False:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/preflight.py` around lines 132 - 133, Update the residential check in the
preflight requirement evaluation to treat a missing reqs["vps_ip"] value as
false, matching the schema default. Resolve that default before the vps_ip
comparison so entries with residential_ip enabled and no vps_ip still follow the
existing hosted-worker EARNS_NOTHING and unverified-worker CHECK_YOURSELF
outcomes.

Comment thread tests/test_egress.py
Comment on lines +724 to +730
task = asyncio.create_task(worker_api._heartbeat_loop())
for _ in range(20):
await asyncio.sleep(0)
if len(calls) >= 3:
break
task.cancel()
assert len(calls) >= 3, "the loop stopped after the first exception"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Await the cancelled task before the test ends.

task.cancel() only requests cancellation. The test returns without giving the loop a chance to process it, so the task can be garbage-collected while pending and asyncio logs "Task was destroyed but it is pending". Await it and suppress CancelledError.

🧹 Proposed fix
+        import contextlib
+
         task = asyncio.create_task(worker_api._heartbeat_loop())
         for _ in range(20):
             await asyncio.sleep(0)
             if len(calls) >= 3:
                 break
         task.cancel()
+        with contextlib.suppress(asyncio.CancelledError):
+            await task
         assert len(calls) >= 3, "the loop stopped after the first exception"
📝 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
task = asyncio.create_task(worker_api._heartbeat_loop())
for _ in range(20):
await asyncio.sleep(0)
if len(calls) >= 3:
break
task.cancel()
assert len(calls) >= 3, "the loop stopped after the first exception"
import contextlib
task = asyncio.create_task(worker_api._heartbeat_loop())
for _ in range(20):
await asyncio.sleep(0)
if len(calls) >= 3:
break
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
assert len(calls) >= 3, "the loop stopped after the first exception"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_egress.py` around lines 724 - 730, Update the test around the
heartbeat task created by worker_api._heartbeat_loop() to await the task after
calling task.cancel(), and suppress the expected CancelledError before the test
exits. Preserve the existing call-count assertion and cancellation behavior.

@GeiserX
GeiserX merged commit a17564c into main Aug 2, 2026
8 checks passed
@GeiserX
GeiserX deleted the feat/egress-conflicts branch August 2, 2026 17:21
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