mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
+3
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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/')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,4 +3,5 @@ export * from './modes'
|
||||
export * from './socket'
|
||||
export * from './sessionSummary'
|
||||
export * from './utils'
|
||||
export * from './version'
|
||||
export type * from './types'
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Bump when CLI↔Server REST/Socket API changes in a breaking way. */
|
||||
export const PROTOCOL_VERSION = 1
|
||||
Reference in New Issue
Block a user