feat: add CLI-server protocol version mismatch detection

- Define PROTOCOL_VERSION constant in shared/src/version.ts
- Server sets X-Hapi-Protocol-Version header on /cli/* responses
- Server includes protocolVersion in /health endpoint
- CLI extracts serverProtocolVersion from API responses
- CLI shows version mismatch hints in both directions
- Add tests for error utilities and version extraction

close #104
This commit is contained in:
weishu
2026-01-27 16:28:52 +08:00
parent 87c79c3b94
commit 1b22fe0f82
9 changed files with 147 additions and 6 deletions
+3 -2
View File
@@ -3,6 +3,7 @@ import type { AgentState, CreateMachineResponse, CreateSessionResponse, RunnerSt
import { AgentStateSchema, CreateMachineResponseSchema, CreateSessionResponseSchema, RunnerStateSchema, MachineMetadataSchema, MetadataSchema } from '@/api/types'
import { configuration } from '@/configuration'
import { getAuthToken } from '@/api/auth'
import { apiValidationError } from '@/utils/errorUtils'
import { ApiMachineClient } from './apiMachine'
import { ApiSessionClient } from './apiSession'
@@ -36,7 +37,7 @@ export class ApiClient {
const parsed = CreateSessionResponseSchema.safeParse(response.data)
if (!parsed.success) {
throw new Error('Invalid /cli/sessions response')
throw apiValidationError('Invalid /cli/sessions response', response)
}
const raw = parsed.data.session
@@ -96,7 +97,7 @@ export class ApiClient {
const parsed = CreateMachineResponseSchema.safeParse(response.data)
if (!parsed.success) {
throw new Error('Invalid /cli/machines response')
throw apiValidationError('Invalid /cli/machines response', response)
}
const raw = parsed.data.machine
+2 -1
View File
@@ -5,6 +5,7 @@ import axios from 'axios'
import type { ZodType } from 'zod'
import { logger } from '@/ui/logger'
import { backoff } from '@/utils/time'
import { apiValidationError } from '@/utils/errorUtils'
import { AsyncLock } from '@/utils/lock'
import type { RawJSONLines } from '@/claude/types'
import { configuration } from '@/configuration'
@@ -281,7 +282,7 @@ export class ApiSessionClient extends EventEmitter {
const parsed = CliMessagesResponseSchema.safeParse(response.data)
if (!parsed.success) {
throw new Error('Invalid /cli/sessions/:id/messages response')
throw apiValidationError('Invalid /cli/sessions/:id/messages response', response)
}
const messages = parsed.data.messages
+10 -1
View File
@@ -1,6 +1,7 @@
import chalk from 'chalk'
import { execFileSync } from 'node:child_process'
import { z } from 'zod'
import { PROTOCOL_VERSION } from '@hapi/protocol'
import type { StartOptions } from '@/claude/runClaude'
import { configuration } from '@/configuration'
import { isRunnerRunningCurrentlyInstalledHappyVersion } from '@/runner/controlClient'
@@ -134,7 +135,7 @@ ${chalk.bold.cyan('Claude Code Options (from `claude --help`):')}
const { runClaude } = await import('@/claude/runClaude')
await runClaude(options)
} catch (error) {
const { message, messageLower, axiosCode, httpStatus, responseErrorText } = extractErrorInfo(error)
const { message, messageLower, axiosCode, httpStatus, responseErrorText, serverProtocolVersion } = extractErrorInfo(error)
if (
axiosCode === 'ECONNREFUSED' ||
@@ -168,6 +169,14 @@ ${chalk.bold.cyan('Claude Code Options (from `claude --help`):')}
console.error(chalk.red('Error:'), message)
}
if (serverProtocolVersion !== undefined && serverProtocolVersion !== PROTOCOL_VERSION) {
if (serverProtocolVersion < PROTOCOL_VERSION) {
console.error(chalk.yellow(` Hint: server protocol version (${serverProtocolVersion}) is behind CLI (${PROTOCOL_VERSION}). Please update the server.`))
} else {
console.error(chalk.yellow(` Hint: CLI protocol version (${PROTOCOL_VERSION}) is behind server (${serverProtocolVersion}). Please update the CLI.`))
}
}
if (process.env.DEBUG) {
console.error(error)
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import { extractErrorInfo, apiValidationError } from './errorUtils'
describe('extractErrorInfo', () => {
it('extracts serverProtocolVersion from axios-style response header', () => {
const error = {
message: 'Request failed with status code 400',
response: {
status: 400,
headers: { 'x-hapi-protocol-version': '2' },
data: { error: 'Invalid body' }
}
}
const info = extractErrorInfo(error)
expect(info.serverProtocolVersion).toBe(2)
expect(info.httpStatus).toBe(400)
expect(info.responseErrorText).toBe('Invalid body')
})
it('extracts serverProtocolVersion from direct property (apiValidationError)', () => {
const error = new Error('Invalid /cli/machines response')
;(error as unknown as Record<string, unknown>).serverProtocolVersion = 1
const info = extractErrorInfo(error)
expect(info.serverProtocolVersion).toBe(1)
expect(info.message).toBe('Invalid /cli/machines response')
})
it('prefers direct property over header', () => {
const error = Object.assign(new Error('test'), {
serverProtocolVersion: 3,
response: {
status: 200,
headers: { 'x-hapi-protocol-version': '5' },
data: {}
}
})
const info = extractErrorInfo(error)
expect(info.serverProtocolVersion).toBe(3)
})
it('returns undefined serverProtocolVersion when neither source present', () => {
const error = new Error('some error')
const info = extractErrorInfo(error)
expect(info.serverProtocolVersion).toBeUndefined()
})
it('handles non-numeric protocol header gracefully', () => {
const error = {
message: 'fail',
response: {
status: 200,
headers: { 'x-hapi-protocol-version': 'abc' },
data: {}
}
}
const info = extractErrorInfo(error)
expect(info.serverProtocolVersion).toBeUndefined()
})
})
describe('apiValidationError', () => {
it('creates error with serverProtocolVersion from response header', () => {
const fakeResponse = {
headers: { 'x-hapi-protocol-version': '1' }
}
const err = apiValidationError('Invalid /cli/machines response', fakeResponse as any)
expect(err.message).toBe('Invalid /cli/machines response')
expect((err as any).serverProtocolVersion).toBe(1)
})
it('creates error without serverProtocolVersion when header missing', () => {
const fakeResponse = { headers: {} }
const err = apiValidationError('Invalid /cli/sessions response', fakeResponse as any)
expect(err.message).toBe('Invalid /cli/sessions response')
expect((err as any).serverProtocolVersion).toBeUndefined()
})
it('round-trips through extractErrorInfo', () => {
const fakeResponse = {
headers: { 'x-hapi-protocol-version': '2' }
}
const err = apiValidationError('Invalid /cli/machines response', fakeResponse as any)
const info = extractErrorInfo(err)
expect(info.serverProtocolVersion).toBe(2)
expect(info.messageLower).toContain('invalid /cli/')
})
})
+37 -1
View File
@@ -2,12 +2,31 @@
* Error handling utilities for API requests
*/
import type { AxiosResponse } from 'axios'
export type ErrorInfo = {
message: string
messageLower: string
axiosCode?: string
httpStatus?: number
responseErrorText: string
serverProtocolVersion?: number
}
/**
* Create an Error for a successful HTTP response whose body fails schema validation.
* Attaches the protocol version header so callers can detect version mismatch.
*/
export function apiValidationError(message: string, response: AxiosResponse): Error {
const err = new Error(message)
const raw = response.headers?.['x-hapi-protocol-version']
if (raw != null) {
const pv = Number(raw)
if (Number.isFinite(pv)) {
;(err as unknown as Record<string, unknown>).serverProtocolVersion = pv
}
}
return err
}
/**
@@ -33,12 +52,29 @@ export function extractErrorInfo(error: unknown): ErrorInfo {
: undefined
const responseErrorText = typeof responseError === 'string' ? responseError : ''
// Protocol version: prefer direct property (set by apiValidationError),
// fall back to axios response header (set by server on error responses)
let serverProtocolVersion: number | undefined
if (typeof record.serverProtocolVersion === 'number' && Number.isFinite(record.serverProtocolVersion)) {
serverProtocolVersion = record.serverProtocolVersion
} else {
const headers = typeof response?.headers === 'object' && response.headers !== null
? (response.headers as Record<string, unknown>)
: undefined
const protocolHeader = headers?.['x-hapi-protocol-version']
if (typeof protocolHeader === 'string' && protocolHeader !== '') {
const pv = Number(protocolHeader)
if (Number.isFinite(pv)) serverProtocolVersion = pv
}
}
return {
message,
messageLower,
axiosCode,
httpStatus,
responseErrorText
responseErrorText,
serverProtocolVersion
}
}
+3
View File
@@ -1,5 +1,6 @@
import { Hono } from 'hono'
import { z } from 'zod'
import { PROTOCOL_VERSION } from '@hapi/protocol'
import { configuration } from '../../configuration'
import { constantTimeEquals } from '../../utils/crypto'
import { parseAccessToken } from '../../utils/accessToken'
@@ -65,6 +66,8 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono<Cl
const app = new Hono<CliEnv>()
app.use('*', async (c, next) => {
c.header('X-Hapi-Protocol-Version', String(PROTOCOL_VERSION))
const raw = c.req.header('authorization')
if (!raw) {
return c.json({ error: 'Missing Authorization header' }, 401)
+2 -1
View File
@@ -5,6 +5,7 @@ import { join } from 'node:path'
import { existsSync } from 'node:fs'
import { serveStatic } from 'hono/bun'
import { configuration } from '../configuration'
import { PROTOCOL_VERSION } from '@hapi/protocol'
import type { SyncEngine } from '../sync/syncEngine'
import { createAuthMiddleware, type WebAppEnv } from './middleware/auth'
import { createAuthRoutes } from './routes/auth'
@@ -70,7 +71,7 @@ function createWebApp(options: {
app.use('*', logger())
// Health check endpoint (no auth required)
app.get('/health', (c) => c.json({ status: 'ok' }))
app.get('/health', (c) => c.json({ status: 'ok', protocolVersion: PROTOCOL_VERSION }))
const corsOrigins = options.corsOrigins ?? configuration.corsOrigins
const corsOriginOption = corsOrigins.includes('*') ? '*' : corsOrigins
+1
View File
@@ -3,4 +3,5 @@ export * from './modes'
export * from './socket'
export * from './sessionSummary'
export * from './utils'
export * from './version'
export type * from './types'
+2
View File
@@ -0,0 +1,2 @@
/** Bump when CLI↔Server REST/Socket API changes in a breaking way. */
export const PROTOCOL_VERSION = 1