From 6044020c441e503590c7c1df41780fe2793223f7 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:38:38 +0200 Subject: [PATCH 1/4] feat: add Salad earnings collector - New SaladCollector using app-api.salad.io/api/v1/profile/balance - Auth via sAccessToken cookie (browser DevTools) - Returns currentBalance in USD - Registered in COLLECTOR_MAP with access_token config key - Updated service YAML and guide docs with actual API details --- app/collectors/__init__.py | 3 ++ app/collectors/salad.py | 62 ++++++++++++++++++++++++++++++++++++++ docs/guides/salad.md | 25 +++++++++------ services/compute/salad.yml | 2 +- 4 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 app/collectors/salad.py diff --git a/app/collectors/__init__.py b/app/collectors/__init__.py index 87fcd69..8269cdf 100644 --- a/app/collectors/__init__.py +++ b/app/collectors/__init__.py @@ -21,6 +21,7 @@ from app.collectors.packetstream import PacketStreamCollector from app.collectors.proxyrack import ProxyRackCollector from app.collectors.repocket import RepocketCollector +from app.collectors.salad import SaladCollector from app.collectors.storj import StorjCollector from app.collectors.traffmonetizer import TraffmonetizerCollector @@ -41,6 +42,7 @@ "packetstream": PacketStreamCollector, "grass": GrassCollector, "bytelixir": BytelixirCollector, + "salad": SaladCollector, } # Map of slug -> list of config keys needed to instantiate the collector @@ -58,6 +60,7 @@ "packetstream": ["auth_token"], "grass": ["access_token"], "bytelixir": ["session_cookie", "?remember_web", "?xsrf_token"], + "salad": ["access_token"], } diff --git a/app/collectors/salad.py b/app/collectors/salad.py new file mode 100644 index 0000000..fe5a632 --- /dev/null +++ b/app/collectors/salad.py @@ -0,0 +1,62 @@ +"""Salad earnings collector. + +Authenticates via session cookie and fetches the current balance from +the Salad API. + +To get the token: open app.salad.io in your browser, log in, press F12, +go to Application > Cookies, and copy the `sAccessToken` value. +""" + +from __future__ import annotations + +import logging + +import httpx + +from app.collectors.base import BaseCollector, EarningsResult + +logger = logging.getLogger(__name__) + +API_BASE = "https://app-api.salad.io/api/v1" + + +class SaladCollector(BaseCollector): + """Collect earnings from Salad's API using the session cookie.""" + + platform = "salad" + + def __init__(self, access_token: str) -> None: + self.access_token = access_token + + async def collect(self) -> EarningsResult: + """Fetch current Salad balance.""" + try: + cookies = {"sAccessToken": self.access_token} + + async with httpx.AsyncClient(timeout=30, cookies=cookies) as client: + resp = await client.get(f"{API_BASE}/profile/balance") + + if resp.status_code in (401, 403): + return EarningsResult( + platform=self.platform, + balance=0.0, + error="Session expired — get a new sAccessToken cookie from app.salad.io", + ) + + resp.raise_for_status() + data = resp.json() + + balance = float(data.get("currentBalance", 0)) + + return EarningsResult( + platform=self.platform, + balance=round(balance, 4), + currency="USD", + ) + except Exception as exc: + logger.error("Salad collection failed: %s", exc) + return EarningsResult( + platform=self.platform, + balance=0.0, + error=str(exc), + ) diff --git a/docs/guides/salad.md b/docs/guides/salad.md index 5a193e0..269d610 100644 --- a/docs/guides/salad.md +++ b/docs/guides/salad.md @@ -33,20 +33,27 @@ Salad.io lets you share your GPU for distributed AI workloads and earn Salad bal ### 1. Create an account -Sign up at [Salad](https://salad.io). +Sign up at [Salad](https://salad.io) and install the desktop application on your Windows machine. -### 2. Get your credentials +### 2. Get your session token -After signing up, locate the credentials needed for Docker deployment. These are typically your email/password or an API token found in the dashboard. +Salad runs as a native Windows app — there is no Docker image. To let CashPilot track your earnings: -### 3. Deploy with CashPilot +1. Open [app.salad.io](https://app.salad.io) in your browser and log in. +2. Press **F12** to open DevTools. +3. Go to **Application > Cookies > app.salad.io**. +4. Copy the value of the `sAccessToken` cookie. -In the CashPilot web UI, find **Salad** in the service catalog and click **Deploy**. Enter the required credentials and CashPilot will handle the rest. +### 3. Configure CashPilot -## Docker Configuration +Add the token to your CashPilot configuration: + +``` +salad_access_token= +``` -- **Image:** `` +CashPilot will poll `app-api.salad.io/api/v1/profile/balance` to fetch your current and lifetime balance. -### Environment Variables +## Docker Configuration -No environment variables required. +Salad is a native Windows desktop application — no Docker image is available. CashPilot monitors earnings via the Salad API only. diff --git a/services/compute/salad.yml b/services/compute/salad.yml index 5491480..e6efd09 100644 --- a/services/compute/salad.yml +++ b/services/compute/salad.yml @@ -53,4 +53,4 @@ platforms: [windows] collector: type: api - notes: "API at app.salad.io. Auth via session token. Earnings and machine status endpoints available." + notes: "API at app-api.salad.io/api/v1/profile/balance. Auth via sAccessToken cookie from browser. Returns currentBalance (USD) and lifetimeBalance." From f151e4f7e9b4df1165db5b89e8a5c4b1aff81e1d Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:54:07 +0200 Subject: [PATCH 2/4] fix: salad API moved to app-api.salad.com with Bearer auth - app-api.salad.io is dead, new base is app-api.salad.com - Auth changed from sAccessToken cookie to Bearer token header - Updated collector, service YAML, and guide docs --- app/collectors/salad.py | 24 ++++++++++++++---------- docs/guides/salad.md | 14 +++++++------- services/compute/salad.yml | 2 +- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/app/collectors/salad.py b/app/collectors/salad.py index fe5a632..5fe9e5c 100644 --- a/app/collectors/salad.py +++ b/app/collectors/salad.py @@ -1,10 +1,11 @@ """Salad earnings collector. -Authenticates via session cookie and fetches the current balance from -the Salad API. +Authenticates via Bearer token and fetches the current balance from +the Salad API at app-api.salad.com. -To get the token: open app.salad.io in your browser, log in, press F12, -go to Application > Cookies, and copy the `sAccessToken` value. +To get the token: open salad.com in your browser, log in, press F12, +go to Network tab, find any request to app-api.salad.com, and copy +the Authorization header value (without the "Bearer " prefix). """ from __future__ import annotations @@ -17,11 +18,11 @@ logger = logging.getLogger(__name__) -API_BASE = "https://app-api.salad.io/api/v1" +API_BASE = "https://app-api.salad.com/api/v1" class SaladCollector(BaseCollector): - """Collect earnings from Salad's API using the session cookie.""" + """Collect earnings from Salad's API using a Bearer token.""" platform = "salad" @@ -31,16 +32,19 @@ def __init__(self, access_token: str) -> None: async def collect(self) -> EarningsResult: """Fetch current Salad balance.""" try: - cookies = {"sAccessToken": self.access_token} + headers = {"Authorization": f"Bearer {self.access_token}"} - async with httpx.AsyncClient(timeout=30, cookies=cookies) as client: - resp = await client.get(f"{API_BASE}/profile/balance") + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{API_BASE}/profile/balance", + headers=headers, + ) if resp.status_code in (401, 403): return EarningsResult( platform=self.platform, balance=0.0, - error="Session expired — get a new sAccessToken cookie from app.salad.io", + error="Token expired — get a new Bearer token from salad.com Network tab", ) resp.raise_for_status() diff --git a/docs/guides/salad.md b/docs/guides/salad.md index 269d610..e9319f3 100644 --- a/docs/guides/salad.md +++ b/docs/guides/salad.md @@ -35,24 +35,24 @@ Salad.io lets you share your GPU for distributed AI workloads and earn Salad bal Sign up at [Salad](https://salad.io) and install the desktop application on your Windows machine. -### 2. Get your session token +### 2. Get your Bearer token Salad runs as a native Windows app — there is no Docker image. To let CashPilot track your earnings: -1. Open [app.salad.io](https://app.salad.io) in your browser and log in. -2. Press **F12** to open DevTools. -3. Go to **Application > Cookies > app.salad.io**. -4. Copy the value of the `sAccessToken` cookie. +1. Open [salad.com](https://salad.com) in your browser and log in. +2. Press **F12** to open DevTools → **Network** tab. +3. Reload the page and look for any request to `app-api.salad.com`. +4. Click the request and copy the `Authorization` header value (without the `Bearer ` prefix). ### 3. Configure CashPilot Add the token to your CashPilot configuration: ``` -salad_access_token= +salad_access_token= ``` -CashPilot will poll `app-api.salad.io/api/v1/profile/balance` to fetch your current and lifetime balance. +CashPilot will poll `app-api.salad.com/api/v1/profile/balance` to fetch your current and lifetime balance. ## Docker Configuration diff --git a/services/compute/salad.yml b/services/compute/salad.yml index e6efd09..1b3750c 100644 --- a/services/compute/salad.yml +++ b/services/compute/salad.yml @@ -53,4 +53,4 @@ platforms: [windows] collector: type: api - notes: "API at app-api.salad.io/api/v1/profile/balance. Auth via sAccessToken cookie from browser. Returns currentBalance (USD) and lifetimeBalance." + notes: "API at app-api.salad.com/api/v1/profile/balance. Auth via Bearer token from browser Network tab. Returns currentBalance (USD) and lifetimeBalance." From 246093200b87e3d52101a429e7ef2edb478ddaa7 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 1 Apr 2026 00:00:13 +0200 Subject: [PATCH 3/4] fix: salad uses auth cookie + XSRF double-submit, not Bearer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tested against live API — the auth mechanism is: - Cookie: auth= - Header: X-XSRF-TOKEN= Verified: returns currentBalance and lifetimeBalance in USD. Config key changed from salad_access_token to salad_auth_cookie. --- app/collectors/__init__.py | 2 +- app/collectors/salad.py | 22 +++++++++++++--------- docs/guides/salad.md | 12 ++++++------ services/compute/salad.yml | 2 +- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/app/collectors/__init__.py b/app/collectors/__init__.py index 8269cdf..39cbcfd 100644 --- a/app/collectors/__init__.py +++ b/app/collectors/__init__.py @@ -60,7 +60,7 @@ "packetstream": ["auth_token"], "grass": ["access_token"], "bytelixir": ["session_cookie", "?remember_web", "?xsrf_token"], - "salad": ["access_token"], + "salad": ["auth_cookie"], } diff --git a/app/collectors/salad.py b/app/collectors/salad.py index 5fe9e5c..f22143c 100644 --- a/app/collectors/salad.py +++ b/app/collectors/salad.py @@ -1,11 +1,13 @@ """Salad earnings collector. -Authenticates via Bearer token and fetches the current balance from -the Salad API at app-api.salad.com. +Authenticates via the ``auth`` cookie and fetches the current balance +from the Salad API at app-api.salad.com. + +Salad uses ASP.NET Core anti-forgery: the ``auth`` cookie value must +also be sent as the ``X-XSRF-TOKEN`` header (double-submit pattern). To get the token: open salad.com in your browser, log in, press F12, -go to Network tab, find any request to app-api.salad.com, and copy -the Authorization header value (without the "Bearer " prefix). +go to Application > Cookies > .salad.com, and copy the ``auth`` cookie. """ from __future__ import annotations @@ -22,21 +24,23 @@ class SaladCollector(BaseCollector): - """Collect earnings from Salad's API using a Bearer token.""" + """Collect earnings from Salad's API using the auth cookie.""" platform = "salad" - def __init__(self, access_token: str) -> None: - self.access_token = access_token + def __init__(self, auth_cookie: str) -> None: + self.auth_cookie = auth_cookie async def collect(self) -> EarningsResult: """Fetch current Salad balance.""" try: - headers = {"Authorization": f"Bearer {self.access_token}"} + cookies = {"auth": self.auth_cookie} + headers = {"X-XSRF-TOKEN": self.auth_cookie} async with httpx.AsyncClient(timeout=30) as client: resp = await client.get( f"{API_BASE}/profile/balance", + cookies=cookies, headers=headers, ) @@ -44,7 +48,7 @@ async def collect(self) -> EarningsResult: return EarningsResult( platform=self.platform, balance=0.0, - error="Token expired — get a new Bearer token from salad.com Network tab", + error="Auth cookie expired — get a new 'auth' cookie from salad.com", ) resp.raise_for_status() diff --git a/docs/guides/salad.md b/docs/guides/salad.md index e9319f3..a333201 100644 --- a/docs/guides/salad.md +++ b/docs/guides/salad.md @@ -35,21 +35,21 @@ Salad.io lets you share your GPU for distributed AI workloads and earn Salad bal Sign up at [Salad](https://salad.io) and install the desktop application on your Windows machine. -### 2. Get your Bearer token +### 2. Get your auth cookie Salad runs as a native Windows app — there is no Docker image. To let CashPilot track your earnings: 1. Open [salad.com](https://salad.com) in your browser and log in. -2. Press **F12** to open DevTools → **Network** tab. -3. Reload the page and look for any request to `app-api.salad.com`. -4. Click the request and copy the `Authorization` header value (without the `Bearer ` prefix). +2. Press **F12** to open DevTools. +3. Go to **Application > Cookies > salad.com**. +4. Copy the value of the `auth` cookie (starts with `CfDJ8...`). ### 3. Configure CashPilot -Add the token to your CashPilot configuration: +Add the cookie to your CashPilot configuration: ``` -salad_access_token= +salad_auth_cookie= ``` CashPilot will poll `app-api.salad.com/api/v1/profile/balance` to fetch your current and lifetime balance. diff --git a/services/compute/salad.yml b/services/compute/salad.yml index 1b3750c..4dd39e4 100644 --- a/services/compute/salad.yml +++ b/services/compute/salad.yml @@ -53,4 +53,4 @@ platforms: [windows] collector: type: api - notes: "API at app-api.salad.com/api/v1/profile/balance. Auth via Bearer token from browser Network tab. Returns currentBalance (USD) and lifetimeBalance." + notes: "API at app-api.salad.com/api/v1/profile/balance. Auth via 'auth' cookie + X-XSRF-TOKEN header (double-submit). Returns currentBalance (USD) and lifetimeBalance." From fa231acae0f040eb90c54cf262c9ca15bc839b34 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 1 Apr 2026 00:01:58 +0200 Subject: [PATCH 4/4] fix: auto-create external deployment for manual-only collectors When saving collector credentials via POST /api/config, auto-insert an "external" deployment record for services that have no Docker image (manual-only). Without this, _run_collection() never instantiates collectors for Salad, Grass, Bytelixir, or any future manual-only service because make_collectors() requires a matching deployment row. Also adds auth_cookie to the secret_args mask list. --- app/main.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/app/main.py b/app/main.py index 65181d7..72b4a17 100644 --- a/app/main.py +++ b/app/main.py @@ -1271,6 +1271,7 @@ async def api_collectors_meta(request: Request) -> list[dict[str, Any]]: "access_token", "api_key", "session_cookie", + "auth_cookie", "oauth_token", "brd_sess_id", } @@ -1318,6 +1319,30 @@ class ConfigUpdate(BaseModel): async def api_set_config(request: Request, body: ConfigUpdate) -> dict[str, str]: _require_owner(request) await database.set_config_bulk(body.data) + + # Auto-create "external" deployment records for manual-only services + # whose collector credentials were just saved. Without a deployment + # row, _run_collection() will never instantiate the collector. + from app.collectors import _COLLECTOR_ARGS + + for slug, arg_keys in _COLLECTOR_ARGS.items(): + required_keys = [f"{slug}_{a.lstrip('?')}" for a in arg_keys if not a.startswith("?")] + if not required_keys: + continue + if not all(body.data.get(k) for k in required_keys): + continue + svc = catalog.get_service(slug) + if not svc: + continue + docker_conf = svc.get("docker", {}) + has_image = bool(docker_conf and docker_conf.get("image")) + if has_image: + continue # Docker services get deployed normally + existing = await database.get_deployment(slug) + if not existing: + await database.save_deployment(slug=slug, container_id="", status="external") + logger.info("Auto-created external deployment for %s", slug) + return {"status": "saved"}