Skip to content

Commit 097d511

Browse files
AbirAbbasclaude
andcommitted
fix(desktop): don't report an unrelated service on 8080 as a running control plane
checkControlPlane treated any HTTP 200 from {baseUrl}/health as "healthy", so anything squatting on the popular default port lit the dashboard green. Found live on Windows: an unrelated dev server answering {"status":"alive"} on /health showed as a running control plane. The probe now recognizes an AgentField control plane by its health payload shape (status: healthy|unhealthy, per routes_core.go). Anything else reachable on the port reports recognized: false with an explanatory error, renders as a yellow "Another service is on this port" state, and is excluded from the nodes cross-check so a foreign /api/v1/nodes response cannot corrupt agent badges. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6bc5fc4 commit 097d511

4 files changed

Lines changed: 89 additions & 15 deletions

File tree

desktop/src/main/agentfield.test.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ describe('checkControlPlane', () => {
170170
}
171171
const fetchImpl: FetchLike = async () => jsonResponse(body, 200)
172172
const result = await checkControlPlane('http://localhost:8080', fetchImpl)
173-
expect(result).toEqual({ reachable: true, healthy: true, raw: body })
173+
expect(result).toEqual({ reachable: true, recognized: true, healthy: true, raw: body })
174174
})
175175

176176
// Contract: 503 with an unhealthy body still means reachable, just not healthy.
@@ -179,17 +179,46 @@ describe('checkControlPlane', () => {
179179
const fetchImpl: FetchLike = async () => jsonResponse(body, 503)
180180
const result = await checkControlPlane('http://localhost:8080', fetchImpl)
181181
expect(result.reachable).toBe(true)
182+
expect(result.recognized).toBe(true)
182183
expect(result.healthy).toBe(false)
183184
expect(result.raw).toEqual(body)
184185
})
185186

187+
// Contract: a 200 from something that is NOT an AgentField control plane
188+
// (default port 8080 is popular) must not read as healthy. Found live on
189+
// Windows: an unrelated dev server answering {"status":"alive"} on /health
190+
// lit the dashboard green.
191+
it('rejects a foreign 200 /health payload as unrecognized', async () => {
192+
const body = { status: 'alive', uptime_s: 3714 }
193+
const fetchImpl: FetchLike = async () => jsonResponse(body, 200)
194+
const result = await checkControlPlane('http://localhost:8080', fetchImpl)
195+
expect(result.reachable).toBe(true)
196+
expect(result.recognized).toBe(false)
197+
expect(result.healthy).toBe(false)
198+
expect(result.error).toContain('does not look like an AgentField control plane')
199+
})
200+
201+
it('rejects a non-JSON 200 response as unrecognized', async () => {
202+
const fetchImpl: FetchLike = async () =>
203+
new Response('<html>hi</html>', { status: 200, headers: { 'content-type': 'text/html' } })
204+
const result = await checkControlPlane('http://localhost:8080', fetchImpl)
205+
expect(result.reachable).toBe(true)
206+
expect(result.recognized).toBe(false)
207+
expect(result.healthy).toBe(false)
208+
})
209+
186210
// Contract: network error / timeout -> not reachable, error captured.
187211
it('maps a rejected fetch to unreachable with an error message', async () => {
188212
const fetchImpl: FetchLike = async () => {
189213
throw new TypeError('fetch failed')
190214
}
191215
const result = await checkControlPlane('http://localhost:8080', fetchImpl)
192-
expect(result).toEqual({ reachable: false, healthy: false, error: 'fetch failed' })
216+
expect(result).toEqual({
217+
reachable: false,
218+
recognized: false,
219+
healthy: false,
220+
error: 'fetch failed'
221+
})
193222
})
194223

195224
it('probes {baseUrl}/health', async () => {
@@ -287,6 +316,27 @@ describe('getSnapshot', () => {
287316
expect(badges).toEqual({ 'pr-af': 'running', 'swe-af': 'stopped' })
288317
})
289318

319+
it('does not consult the nodes view of an unrecognized service on the port', async () => {
320+
const home = await makeHome(REGISTRY_FIXTURE)
321+
const requested: string[] = []
322+
const fetchImpl: FetchLike = async (input) => {
323+
requested.push(String(input))
324+
// A foreign service that would answer BOTH endpoints with junk.
325+
return jsonResponse({ status: 'alive', nodes: [] })
326+
}
327+
328+
const snapshot = await getSnapshot({ homeDir: home, fetchImpl })
329+
330+
expect(snapshot.controlPlane.recognized).toBe(false)
331+
// Badges fall back to registry statuses — the foreign 200 on /api/v1/nodes
332+
// must not flip a running agent to unknown.
333+
const badges = Object.fromEntries(
334+
snapshot.registry.agents.map((a) => [a.name, a.badge])
335+
)
336+
expect(badges).toEqual({ 'pr-af': 'running', 'swe-af': 'stopped' })
337+
expect(requested.some((url) => url.endsWith('/api/v1/nodes'))).toBe(false)
338+
})
339+
290340
it('reports an unreachable control plane and an absent registry gracefully', async () => {
291341
const home = await makeHome()
292342
const missing = path.join(home, 'nope')

desktop/src/main/agentfield.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,14 @@ function errorMessage(err: unknown): string {
4343

4444
/**
4545
* Probe GET {baseUrl}/health.
46-
* - 200 {"status":"healthy",...} -> { reachable: true, healthy: true }
47-
* - 503 {"status":"unhealthy",...} -> { reachable: true, healthy: false }
46+
* - 200 {"status":"healthy",...} -> { reachable: true, recognized: true, healthy: true }
47+
* - 503 {"status":"unhealthy",...} -> { reachable: true, recognized: true, healthy: false }
4848
* (an HTTP response — even 503 — still means the control plane is reachable)
49-
* - network error / timeout (3s) -> { reachable: false, healthy: false, error }
49+
* - any response whose body is not an AgentField health payload
50+
* -> { reachable: true, recognized: false, healthy: false, error }
51+
* (default port 8080 is popular — an unrelated dev server answering 200 on
52+
* /health must not light up the dashboard as a running control plane)
53+
* - network error / timeout (3s) -> { reachable: false, recognized: false, healthy: false, error }
5054
*/
5155
export async function checkControlPlane(
5256
baseUrl: string = DEFAULT_BASE_URL,
@@ -62,9 +66,20 @@ export async function checkControlPlane(
6266
} catch {
6367
raw = undefined
6468
}
65-
return { reachable: true, healthy: res.ok, raw }
69+
const status = isRecord(raw) && typeof raw.status === 'string' ? raw.status : undefined
70+
const recognized = status === 'healthy' || status === 'unhealthy'
71+
if (!recognized) {
72+
return {
73+
reachable: true,
74+
recognized: false,
75+
healthy: false,
76+
raw,
77+
error: `A service answered ${baseUrl}/health but does not look like an AgentField control plane — another app may be using the port.`
78+
}
79+
}
80+
return { reachable: true, recognized: true, healthy: res.ok && status === 'healthy', raw }
6681
} catch (err) {
67-
return { reachable: false, healthy: false, error: errorMessage(err) }
82+
return { reachable: false, recognized: false, healthy: false, error: errorMessage(err) }
6883
}
6984
}
7085

@@ -197,7 +212,9 @@ export async function getSnapshot(options: SnapshotOptions = {}): Promise<AgentF
197212
readInstalledAgents(options.homeDir)
198213
])
199214

200-
const nodeIds = controlPlane.reachable
215+
// Only cross-check against a recognized control plane; an unrelated service
216+
// on the port must not influence the badges.
217+
const nodeIds = controlPlane.recognized
201218
? await fetchControlPlaneNodes(baseUrl, fetchImpl)
202219
: null
203220
const hasControlPlaneView = nodeIds !== null

desktop/src/renderer/src/components/ControlPlaneCard.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@ export function ControlPlaneCard({ controlPlane }: ControlPlaneCardProps) {
88
let dotClass = 'gray'
99
let label = 'Checking…'
1010
if (controlPlane) {
11-
if (controlPlane.reachable && controlPlane.healthy) {
11+
if (controlPlane.healthy) {
1212
dotClass = 'green'
1313
label = 'Running'
14-
} else if (controlPlane.reachable) {
14+
} else if (controlPlane.reachable && controlPlane.recognized) {
1515
dotClass = 'yellow'
1616
label = 'Reachable (unhealthy)'
17+
} else if (controlPlane.reachable) {
18+
dotClass = 'yellow'
19+
label = 'Another service is on this port'
1720
} else {
1821
dotClass = 'red'
1922
label = 'Not reachable'
@@ -30,9 +33,7 @@ export function ControlPlaneCard({ controlPlane }: ControlPlaneCardProps) {
3033
<span className={`status-dot ${dotClass}`} aria-hidden="true" />
3134
<span className="status-label">{label}</span>
3235
</div>
33-
{controlPlane && !controlPlane.reachable && controlPlane.error && (
34-
<p className="muted small">{controlPlane.error}</p>
35-
)}
36+
{controlPlane?.error && <p className="muted small">{controlPlane.error}</p>}
3637
</section>
3738
)
3839
}

desktop/src/shared/types.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,17 @@
55
export interface ControlPlaneStatus {
66
/** An HTTP response came back (any status code, including 503). */
77
reachable: boolean
8-
/** The health endpoint answered 200 (body reports "healthy"). */
8+
/**
9+
* The response body looks like an AgentField control plane health payload
10+
* (status: "healthy" | "unhealthy"). False when some unrelated service is
11+
* squatting on the port — its nodes view must not be trusted.
12+
*/
13+
recognized: boolean
14+
/** The health endpoint answered 200 with a body reporting "healthy". */
915
healthy: boolean
1016
/** Raw JSON body of the health response, when one was parseable. */
1117
raw?: unknown
12-
/** Network/timeout error message when unreachable. */
18+
/** Network/timeout error when unreachable, or why the payload was rejected. */
1319
error?: string
1420
}
1521

0 commit comments

Comments
 (0)