Skip to content

Commit b2574ce

Browse files
zahidzorbazclaude
andcommitted
fix(devframe): only accept a JSON object as connection meta
`probeDevframeOrigin` took any 2xx answer as a live devframe and fell back to `{}` when the body did not parse. A host with an SPA fallback (Vite serving `index.html` for unknown paths) therefore passed as a devframe with no MCP route: `devframe connect --port` listed a phantom instance and failed with DF0051 instead of DF0050, and registry liveness checks kept stale records whose port now serves an unrelated app. A candidate now counts only when it answers with a JSON object. Refs #403 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 18fa60e commit b2574ce

2 files changed

Lines changed: 92 additions & 4 deletions

File tree

‎packages/devframe/src/node/instance-registry.test.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { join } from 'pathe'
77
import { beforeEach, describe, expect, it, vi } from 'vitest'
88
import {
99
listLiveDevframeInstances,
10+
probeDevframeOrigin,
1011
readDevframeInstances,
1112
registerDevframeInstance,
1213
} from './instance-registry'
@@ -142,3 +143,82 @@ describe('instance registry', () => {
142143
}
143144
})
144145
})
146+
147+
describe('probeDevframeOrigin', () => {
148+
// A host with an SPA fallback: every unknown path answers `200 text/html`
149+
// (Vite serving `index.html`); the real meta lives under `/__devtools/`.
150+
async function startSpaFallbackServer(): Promise<{ origin: string, port: number, close: () => Promise<void> }> {
151+
const server = createServer((req, res) => {
152+
if (req.url === '/__devtools/__connection.json') {
153+
res.writeHead(200, { 'content-type': 'application/json' })
154+
res.end('{"backend":"websocket","mcp":{"path":"__mcp"}}')
155+
return
156+
}
157+
if (req.url === '/array/__connection.json') {
158+
res.writeHead(200, { 'content-type': 'application/json' })
159+
res.end('[]')
160+
return
161+
}
162+
res.writeHead(200, { 'content-type': 'text/html' })
163+
res.end('<!doctype html><html><body><div id="app"></div></body></html>')
164+
})
165+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
166+
const port = (server.address() as AddressInfo).port
167+
return {
168+
origin: `http://127.0.0.1:${port}`,
169+
port,
170+
close: () => new Promise<void>(resolve => server.close(() => resolve())),
171+
}
172+
}
173+
174+
it('does not take an HTML SPA fallback for a devframe', async () => {
175+
const spa = await startSpaFallbackServer()
176+
try {
177+
expect(await probeDevframeOrigin(spa.origin, '/', 2000)).toBeNull()
178+
}
179+
finally {
180+
await spa.close()
181+
}
182+
})
183+
184+
it('does not take a non-object JSON body for connection meta', async () => {
185+
const spa = await startSpaFallbackServer()
186+
try {
187+
expect(await probeDevframeOrigin(spa.origin, '/array/', 2000)).toBeNull()
188+
}
189+
finally {
190+
await spa.close()
191+
}
192+
})
193+
194+
it('finds the connection meta under a non-root base', async () => {
195+
const spa = await startSpaFallbackServer()
196+
try {
197+
const probed = await probeDevframeOrigin(spa.origin, '/__devtools/', 2000)
198+
expect(probed).toEqual({ origin: spa.origin, meta: { backend: 'websocket', mcp: { path: '__mcp' } } })
199+
}
200+
finally {
201+
await spa.close()
202+
}
203+
})
204+
205+
it('prunes a registry record whose port now serves an unrelated SPA', async () => {
206+
const dir = mkdtempSync(join(tmpdir(), 'devframe-registry-'))
207+
const spa = await startSpaFallbackServer()
208+
try {
209+
registerDevframeInstance(makeRecord({
210+
pid: 2000,
211+
port: spa.port,
212+
origin: spa.origin,
213+
}), { instancesDir: dir })
214+
215+
const { live, pruned } = await listLiveDevframeInstances({ instancesDir: dir, timeoutMs: 2000 })
216+
expect(live).toEqual([])
217+
expect(pruned.map(r => r.pid)).toEqual([2000])
218+
expect(readdirSync(dir)).toEqual([])
219+
}
220+
finally {
221+
await spa.close()
222+
}
223+
})
224+
})

‎packages/devframe/src/node/instance-registry.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -180,15 +180,17 @@ function originCandidates(origin: string): string[] {
180180
export interface ProbedDevframeOrigin {
181181
/** The origin that answered (may be an explicit address family for a `localhost` bind). */
182182
origin: string
183-
/** The parsed `__connection.json` payload (`{}` when unparseable). */
183+
/** The parsed `__connection.json` payload (always a JSON object). */
184184
meta: { mcp?: { path: string, port?: number } }
185185
}
186186

187187
/**
188188
* Probe `<origin><basePath>__connection.json`, trying each dialable
189189
* candidate for the origin (see {@link originCandidates}). The single
190190
* probe primitive behind both registry liveness checks and the
191-
* connector's explicit `--port` probes.
191+
* connector's explicit `--port` probes. A candidate counts only when it
192+
* answers `2xx` with a JSON object; anything else (an HTML SPA fallback, a
193+
* JSON array, an unparseable body) is treated as "no devframe here".
192194
*
193195
* @internal
194196
*/
@@ -205,8 +207,14 @@ export async function probeDevframeOrigin(
205207
})
206208
if (!response.ok)
207209
continue
208-
const meta = await response.json().catch(() => ({})) as ProbedDevframeOrigin['meta']
209-
return { origin: candidate, meta }
210+
// Only a JSON object is connection meta. Host frameworks with an SPA
211+
// fallback (Vite serving `index.html` for any unknown path) answer a
212+
// wrong base with `200 text/html`; that is not a devframe, so it must
213+
// not pass as a live instance with no MCP route.
214+
const meta: unknown = await response.json().catch(() => undefined)
215+
if (!meta || typeof meta !== 'object' || Array.isArray(meta))
216+
continue
217+
return { origin: candidate, meta: meta as ProbedDevframeOrigin['meta'] }
210218
}
211219
catch {
212220
// Try the next candidate.

0 commit comments

Comments
 (0)