From 1b22fe0f8238ff265e6283ca17d658460c08c7e0 Mon Sep 17 00:00:00 2001 From: weishu Date: Tue, 27 Jan 2026 16:28:18 +0800 Subject: [PATCH] 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 --- cli/src/api/api.ts | 5 +- cli/src/api/apiSession.ts | 3 +- cli/src/commands/claude.ts | 11 +++- cli/src/utils/errorUtils.test.ts | 87 ++++++++++++++++++++++++++++++++ cli/src/utils/errorUtils.ts | 38 +++++++++++++- server/src/web/routes/cli.ts | 3 ++ server/src/web/server.ts | 3 +- shared/src/index.ts | 1 + shared/src/version.ts | 2 + 9 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 cli/src/utils/errorUtils.test.ts create mode 100644 shared/src/version.ts diff --git a/cli/src/api/api.ts b/cli/src/api/api.ts index 0ad52f33..97999de8 100644 --- a/cli/src/api/api.ts +++ b/cli/src/api/api.ts @@ -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 diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index e2dde705..9a5ba2b4 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -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 diff --git a/cli/src/commands/claude.ts b/cli/src/commands/claude.ts index 63b9b886..4b38f2b3 100644 --- a/cli/src/commands/claude.ts +++ b/cli/src/commands/claude.ts @@ -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) } diff --git a/cli/src/utils/errorUtils.test.ts b/cli/src/utils/errorUtils.test.ts new file mode 100644 index 00000000..56249173 --- /dev/null +++ b/cli/src/utils/errorUtils.test.ts @@ -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).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/') + }) +}) diff --git a/cli/src/utils/errorUtils.ts b/cli/src/utils/errorUtils.ts index c30a0548..4133e678 100644 --- a/cli/src/utils/errorUtils.ts +++ b/cli/src/utils/errorUtils.ts @@ -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).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) + : 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 } } diff --git a/server/src/web/routes/cli.ts b/server/src/web/routes/cli.ts index f85ea03e..c8fe85fd 100644 --- a/server/src/web/routes/cli.ts +++ b/server/src/web/routes/cli.ts @@ -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() 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) diff --git a/server/src/web/server.ts b/server/src/web/server.ts index 51bfb3f0..605409e8 100644 --- a/server/src/web/server.ts +++ b/server/src/web/server.ts @@ -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 diff --git a/shared/src/index.ts b/shared/src/index.ts index 3ec6f183..e9e6e350 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -3,4 +3,5 @@ export * from './modes' export * from './socket' export * from './sessionSummary' export * from './utils' +export * from './version' export type * from './types' diff --git a/shared/src/version.ts b/shared/src/version.ts new file mode 100644 index 00000000..bc1013c3 --- /dev/null +++ b/shared/src/version.ts @@ -0,0 +1,2 @@ +/** Bump when CLI↔Server REST/Socket API changes in a breaking way. */ +export const PROTOCOL_VERSION = 1