Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.0] - 2026-07-11

### Added

- **Per-worker fleet keys.** The app now enrolls automatically on its first heartbeat against a CashPilot server (v1.0.0+): it receives its own per-worker key, persists it, and authenticates every subsequent heartbeat with it. The configured fleet API key becomes an enrollment-only bootstrap credential. No setup change is needed — existing devices re-enroll on their next heartbeat. Interoperates with both the CashPilot web UI and CashPilot-Desktop's fleet server. If the server ever rejects the per-worker key (HTTP 401), the app clears it and automatically re-enrolls on the next heartbeat. Heartbeats also keep working on the per-worker key even if the shared bootstrap key is later removed from Settings.

## [0.1.0] - 2026-03-31

### Added
Expand Down
16 changes: 16 additions & 0 deletions app/src/main/java/com/cashpilot/android/model/Heartbeat.kt
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,19 @@ data class AppStatus(
@SerialName("net_rx_24h") val netRx24h: Long = 0,
@SerialName("last_active") val lastActive: String? = null,
)

/**
* Response from POST /api/workers/heartbeat.
*
* On this device's first (enrollment) heartbeat — and re-delivered on each
* subsequent heartbeat until the device confirms it — the server returns a
* per-worker key in [workerKey]. The client persists it and authenticates with
* it thereafter (the shared key becomes enrollment-only). [workerKey] is absent
* once the device is enrolled and confirmed.
*/
@Serializable
data class WorkerHeartbeatResponse(
val status: String = "",
@SerialName("worker_id") val workerId: Long? = null,
@SerialName("worker_key") val workerKey: String? = null,
)
14 changes: 13 additions & 1 deletion app/src/main/java/com/cashpilot/android/model/Settings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,16 @@ data class Settings(
val heartbeatIntervalSeconds: Int = 30,
val enabledSlugs: Set<String> = KnownApps.all.map { it.slug }.toSet(),
val setupCompleted: Boolean = false,
)
/**
* Per-worker fleet key issued by the server on enrollment. Empty until this
* device has enrolled; once set it is used for auth instead of [apiKey] (the
* shared key becomes an enrollment-only bootstrap credential).
*
* Kept LAST so it does not shift the positional component order used by
* `Settings` destructuring in tests.
*/
val workerKey: String = "",
) {
/** The key to authenticate heartbeats with: our own once enrolled, else the shared bootstrap key. */
val activeKey: String get() = workerKey.ifBlank { apiKey }
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import com.cashpilot.android.model.AppContainer
import com.cashpilot.android.model.Settings
import com.cashpilot.android.model.SystemInfo
import com.cashpilot.android.model.WorkerHeartbeat
import com.cashpilot.android.model.WorkerHeartbeatResponse
import com.cashpilot.android.util.SettingsStore
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
Expand Down Expand Up @@ -71,7 +73,7 @@ class HeartbeatService : Service() {
heartbeatJob = scope.launch {
while (true) {
val settings = SettingsStore.settings(applicationContext).first()
if (settings.serverUrl.isNotBlank() && settings.apiKey.isNotBlank()) {
if (settings.serverUrl.isNotBlank() && settings.activeKey.isNotBlank()) {
sendHeartbeat(settings)
}
// Exponential backoff on consecutive failures (30s → 60s → 120s, max 5min)
Expand Down Expand Up @@ -122,21 +124,35 @@ class HeartbeatService : Service() {
val url = settings.serverUrl.trimEnd('/') + "/api/workers/heartbeat"
val response: HttpResponse = httpClient.post(url) {
contentType(ContentType.Application.Json)
bearerAuth(settings.apiKey)
bearerAuth(settings.activeKey)
setBody(heartbeat)
}

if (response.status.isSuccess()) {
consecutiveFailures = 0
_lastHeartbeat.value = System.currentTimeMillis()
_lastHeartbeatFailed.value = false
// Enrollment: on first contact (and re-delivered until we confirm it
// by using it) the server returns this device's own fleet key. Persist
// and adopt it, so subsequent heartbeats authenticate with our own key.
val body = runCatching { response.body<WorkerHeartbeatResponse>() }.getOrNull()
if (body != null) {
settingsAfterHeartbeat(settings, body)?.let { updated ->
SettingsStore.update(applicationContext) { updated }
Log.i(TAG, "Enrolled: received and persisted this device's own fleet key")
}
}
val runningCount = apps.count { it.running }
updateNotification("$runningCount/${apps.size} apps running")
} else {
consecutiveFailures++
_lastHeartbeatFailed.value = true
Log.w(TAG, "Heartbeat rejected: HTTP ${response.status.value}")
updateNotification("Server rejected heartbeat (${response.status.value})")
if (response.status.value == 401 && settings.workerKey.isNotBlank()) {
SettingsStore.update(applicationContext) { it.copy(workerKey = "") }
Log.i(TAG, "Auth rejected (401) — clearing per-worker key to re-enroll")
}
}
} catch (e: Exception) {
consecutiveFailures++
Expand Down Expand Up @@ -198,5 +214,21 @@ class HeartbeatService : Service() {
/** Whether the last heartbeat attempt failed. */
private val _lastHeartbeatFailed = MutableStateFlow(false)
val lastHeartbeatFailed: StateFlow<Boolean> = _lastHeartbeatFailed.asStateFlow()

/**
* The per-worker key to newly persist, given the currently stored key and the
* one the server returned on this heartbeat — or `null` if nothing should
* change (no key issued, blank, or unchanged). Pure, so it is unit-tested.
*/
fun keyToPersist(current: String, issued: String?): String? =
issued?.takeIf { it.isNotBlank() && it != current }

/**
* The [Settings] to persist after a successful heartbeat, given the current
* settings and the parsed response body — or `null` if nothing should change.
* Pure, so the response→persist wiring is unit-tested without Robolectric.
*/
fun settingsAfterHeartbeat(settings: Settings, body: WorkerHeartbeatResponse): Settings? =
keyToPersist(settings.workerKey, body.workerKey)?.let { newKey -> settings.copy(workerKey = newKey) }
}
}
4 changes: 4 additions & 0 deletions app/src/main/java/com/cashpilot/android/util/SettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
object SettingsStore {
private val SERVER_URL = stringPreferencesKey("server_url")
private val API_KEY = stringPreferencesKey("api_key")
private val WORKER_KEY = stringPreferencesKey("worker_key")
private val HEARTBEAT_INTERVAL = intPreferencesKey("heartbeat_interval")
private val ENABLED_SLUGS = stringSetPreferencesKey("enabled_slugs")
private val SETUP_COMPLETED = booleanPreferencesKey("setup_completed")
Expand All @@ -28,6 +29,7 @@ object SettingsStore {
Settings(
serverUrl = prefs[SERVER_URL] ?: "",
apiKey = prefs[API_KEY] ?: "",
workerKey = prefs[WORKER_KEY] ?: "",
heartbeatIntervalSeconds = prefs[HEARTBEAT_INTERVAL] ?: 30,
enabledSlugs = prefs[ENABLED_SLUGS] ?: KnownApps.all.map { it.slug }.toSet(),
// Migration: mark as completed only if both URL and key were configured before this field existed
Expand All @@ -41,6 +43,7 @@ object SettingsStore {
val current = Settings(
serverUrl = prefs[SERVER_URL] ?: "",
apiKey = prefs[API_KEY] ?: "",
workerKey = prefs[WORKER_KEY] ?: "",
heartbeatIntervalSeconds = prefs[HEARTBEAT_INTERVAL] ?: 30,
enabledSlugs = prefs[ENABLED_SLUGS] ?: KnownApps.all.map { it.slug }.toSet(),
setupCompleted = prefs[SETUP_COMPLETED]
Expand All @@ -49,6 +52,7 @@ object SettingsStore {
val updated = transform(current)
prefs[SERVER_URL] = updated.serverUrl
prefs[API_KEY] = updated.apiKey
prefs[WORKER_KEY] = updated.workerKey
prefs[HEARTBEAT_INTERVAL] = updated.heartbeatIntervalSeconds
prefs[ENABLED_SLUGS] = updated.enabledSlugs
prefs[SETUP_COMPLETED] = updated.setupCompleted
Expand Down
89 changes: 89 additions & 0 deletions app/src/test/java/com/cashpilot/android/PerWorkerKeyTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.cashpilot.android

import com.cashpilot.android.model.Settings
import com.cashpilot.android.model.WorkerHeartbeatResponse
import com.cashpilot.android.service.HeartbeatService
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test

/** Per-worker fleet key: active-key selection, enrollment capture, response parsing. */
class PerWorkerKeyTest {
private val json = Json { ignoreUnknownKeys = true }

// --- Settings.activeKey -------------------------------------------------

@Test
fun `activeKey uses the shared api key before enrollment`() {
assertEquals("shared", Settings(apiKey = "shared", workerKey = "").activeKey)
}

@Test
fun `activeKey prefers the per-worker key once enrolled`() {
assertEquals("own", Settings(apiKey = "shared", workerKey = "own").activeKey)
}

// --- HeartbeatService.keyToPersist -------------------------------------

@Test
fun `keyToPersist adopts a newly issued key`() {
assertEquals("issued", HeartbeatService.keyToPersist(current = "", issued = "issued"))
}

@Test
fun `keyToPersist ignores an unchanged key`() {
assertNull(HeartbeatService.keyToPersist(current = "own", issued = "own"))
}

@Test
fun `keyToPersist ignores absent or blank keys`() {
assertNull(HeartbeatService.keyToPersist(current = "own", issued = null))
assertNull(HeartbeatService.keyToPersist(current = "own", issued = ""))
assertNull(HeartbeatService.keyToPersist(current = "own", issued = " "))
}

// --- HeartbeatService.settingsAfterHeartbeat ----------------------------

@Test
fun `settingsAfterHeartbeat adopts a newly issued worker key`() {
val settings = Settings(apiKey = "shared", workerKey = "")
val body = WorkerHeartbeatResponse(status = "ok", workerKey = "issued")
assertEquals("issued", HeartbeatService.settingsAfterHeartbeat(settings, body)?.workerKey)
}

@Test
fun `settingsAfterHeartbeat returns null when the worker key is unchanged, blank, or absent`() {
val settings = Settings(apiKey = "shared", workerKey = "own")
assertNull(HeartbeatService.settingsAfterHeartbeat(settings, WorkerHeartbeatResponse(status = "ok", workerKey = "own")))
assertNull(HeartbeatService.settingsAfterHeartbeat(settings, WorkerHeartbeatResponse(status = "ok", workerKey = "")))
assertNull(HeartbeatService.settingsAfterHeartbeat(settings, WorkerHeartbeatResponse(status = "ok", workerKey = null)))
}

@Test
fun `settingsAfterHeartbeat compares against the current worker key, not the api key`() {
val settings = Settings(apiKey = "different", workerKey = "own")
val body = WorkerHeartbeatResponse(status = "ok", workerKey = "own")
assertNull(HeartbeatService.settingsAfterHeartbeat(settings, body))
}

// --- WorkerHeartbeatResponse deserialization ---------------------------

@Test
fun `response parses worker_key and worker_id when present`() {
val r = json.decodeFromString<WorkerHeartbeatResponse>(
"""{"status":"ok","worker_id":1,"worker_key":"abc123"}""",
)
assertEquals("abc123", r.workerKey)
assertEquals(1L, r.workerId)
}

@Test
fun `response has null worker_key when absent and ignores unknown fields`() {
val r = json.decodeFromString<WorkerHeartbeatResponse>(
"""{"status":"ok","worker_id":2,"some_future_field":"ignored"}""",
)
assertNull(r.workerKey)
assertEquals(2L, r.workerId)
}
}
25 changes: 25 additions & 0 deletions docs/AUTOPILOT-WORKLOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Autopilot Worklog — per-worker keys for Android + Desktop-fleet compat

Append-only. Newest at the bottom. Every "done" needs evidence (test/build/commit).

---

### 2026-07-11 — kickoff
- Goal (docs/GOAL.md): per-worker fleet keys for **CashPilot-android** (client) + **CashPilot-Desktop**
fleet_server (server), interoperable with the web UI v1.0.0 protocol.
- Branches: android `feat/per-worker-keys`; desktop `feat/fleet-per-worker-keys` (off origin/main).
- Beads filed:
- android: CashPilot-android-btz (client enrollment, P1), -a5d (response model), -nvn (tests), -20v (changelog)
- desktop: CashPilot-Desktop-jet (fleet server enrollment, P1), -xkf (store+migration, P1), -10u (tests), -rxx (docs)
- Protocol contract (from web UI v1.0.0): heartbeat with own key once enrolled, else shared bootstrap key;
server returns `worker_key` once on enroll; reissue until confirmed; reject shared key for confirmed device.
- Next: implement android client (btz/a5d) then desktop server (jet/xkf), verify each.

### 2026-07-11 — both sides implemented + PRs open
- Android (client): PR #37 — Settings.workerKey/activeKey, SettingsStore persist, WorkerHeartbeatResponse,
HeartbeatService capture via pure keyToPersist; v0.2.0; PerWorkerKeyTest. Beads btz/a5d/nvn/20v closed.
CI compile break (Settings destructuring order) fixed by moving workerKey last (commit bacf9f1).
- Desktop (server): PR #91 — store api_key_hash+key_confirmed + guarded migration; classifyFleetAuth
drives handleWorkerHeartbeat (enroll/reissue/confirm/reject); tests. Beads jet/xkf/10u/rxx closed.
Verified locally: go build/vet clean, go test -race ./... green.
- Next: confirm both CIs green, address CodeRabbit, then architect review + cancel.
33 changes: 33 additions & 0 deletions docs/GOAL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# GOAL

Started: 2026-07-11

## Directive (verbatim)

that app. Also it should be compatible with cashpilot-desktop, so file beads there and start implementing extensively and thoroughly with ralph

## Established context (from the preceding conversation)

"That app" = **CashPilot-android** (the mobile heartbeat client). The CashPilot **web** UI already
shipped **per-worker fleet keys (v1.0.0)**: on a worker's first heartbeat the server issues it a unique
key (returned once as `worker_key`), which the client persists and uses thereafter; the shared
`CASHPILOT_API_KEY` becomes enrollment-only, and is rejected for a confirmed worker. There is a
re-delivery ("reissue") safety net so a dropped enrollment response can't lock a client out.

**Goal:** bring **CashPilot-android** onto the same per-worker-key protocol as a client (mirror the
Docker worker's client side), AND make **CashPilot-Desktop** compatible — its `fleet_server.go` must
implement the server side of the same enrollment protocol (enroll → issue key → confirm → reject shared
key), so the desktop's fleet server and the android client interoperate. File beads in both repos.

Client side (android, Kotlin/Ktor, DataStore):
- On heartbeat, use the persisted per-worker key if present, else the shared key (bootstrap).
- Capture `worker_key` from the heartbeat response, persist it (DataStore), use it thereafter.

Server side (desktop, Go, fleet_server.go):
- Enrollment + per-worker key issuance/storage + confirm + reject-shared-once-confirmed + reissue,
matching the web UI's protocol so any client (android, docker worker) works against it.

## Working rules
- Per-repo conventions: android = Gradle/Kotlin, `./gradlew test` + ktlint/detekt if present; desktop =
Go, `go build/vet/test -race`. Keep CI green. Conventional commits. PR workflow; never merge without
Sergio's approval. No AI attribution. Beads via `bd`, committed `.beads`, commit via PR.
4 changes: 2 additions & 2 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
VERSION_NAME=0.1.3
VERSION_CODE=103
VERSION_NAME=0.2.0
VERSION_CODE=200