mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(cli): support optional extra headers for hub requests (#445)
* feat(cli): support extra headers for hub requests * fix(types): normalize missing session fields to null * refactor(cli): simplify socket extra headers config
This commit is contained in:
@@ -80,6 +80,7 @@ See `src/configuration.ts` for all options.
|
||||
|
||||
- `HAPI_HOME` - Config/data directory (default: ~/.hapi).
|
||||
- `HAPI_EXPERIMENTAL` - Enable experimental features (true/1/yes).
|
||||
- `HAPI_EXTRA_HEADERS_JSON` - JSON object of extra headers to send on CLI → hub requests, e.g. `{"Cookie":"CF_Authorization=..."}`.
|
||||
- `HAPI_CLAUDE_PATH` - Path to a specific `claude` executable.
|
||||
- `HAPI_HTTP_MCP_URL` - Default MCP target for `hapi mcp`.
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { configuration } from '@/configuration'
|
||||
|
||||
const axiosPostMock = vi.hoisted(() => vi.fn())
|
||||
const ioMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
post: axiosPostMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
getAuthToken: () => 'cli-token'
|
||||
}))
|
||||
|
||||
vi.mock('socket.io-client', () => ({
|
||||
io: ioMock
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc/RpcHandlerManager', () => ({
|
||||
RpcHandlerManager: class {
|
||||
onSocketConnect(): void { }
|
||||
onSocketDisconnect(): void { }
|
||||
registerHandler(): void { }
|
||||
handleRequest(): Promise<string> {
|
||||
return Promise.resolve('{}')
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../modules/common/registerCommonHandlers', () => ({
|
||||
registerCommonHandlers: () => { }
|
||||
}))
|
||||
|
||||
vi.mock('@/terminal/TerminalManager', () => ({
|
||||
TerminalManager: class {
|
||||
closeAll(): void { }
|
||||
}
|
||||
}))
|
||||
|
||||
import { ApiClient } from './api'
|
||||
import { ApiSessionClient } from './apiSession'
|
||||
|
||||
describe('API extra headers integration', () => {
|
||||
const now = 1_710_000_000_000
|
||||
|
||||
beforeEach(() => {
|
||||
configuration._setApiUrl('https://hapi.example.com')
|
||||
configuration._setExtraHeaders({})
|
||||
axiosPostMock.mockReset()
|
||||
ioMock.mockReset()
|
||||
})
|
||||
|
||||
it('adds extra headers to REST requests', async () => {
|
||||
configuration._setExtraHeaders({
|
||||
Cookie: 'CF_Authorization=token'
|
||||
})
|
||||
|
||||
axiosPostMock.mockResolvedValue({
|
||||
data: {
|
||||
session: {
|
||||
id: 'session-1',
|
||||
namespace: 'default',
|
||||
seq: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
active: true,
|
||||
activeAt: now,
|
||||
metadata: {
|
||||
path: '/tmp/project',
|
||||
host: 'test-host'
|
||||
},
|
||||
metadataVersion: 0,
|
||||
agentState: null,
|
||||
agentStateVersion: 0,
|
||||
thinking: false,
|
||||
thinkingAt: now,
|
||||
todos: [],
|
||||
model: null,
|
||||
modelReasoningEffort: null,
|
||||
effort: null,
|
||||
permissionMode: undefined,
|
||||
collaborationMode: undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const client = await ApiClient.create()
|
||||
await client.getOrCreateSession({
|
||||
tag: 'test',
|
||||
metadata: {
|
||||
path: '/tmp/project',
|
||||
host: 'test-host'
|
||||
},
|
||||
state: null
|
||||
})
|
||||
|
||||
expect(axiosPostMock).toHaveBeenCalledOnce()
|
||||
expect(axiosPostMock.mock.calls[0]?.[2]).toMatchObject({
|
||||
headers: {
|
||||
Cookie: 'CF_Authorization=token',
|
||||
Authorization: 'Bearer cli-token',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('adds extra headers to socket transport options', () => {
|
||||
configuration._setExtraHeaders({
|
||||
Cookie: 'CF_Authorization=token'
|
||||
})
|
||||
|
||||
const fakeSocket = {
|
||||
on: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
volatile: { emit: vi.fn() }
|
||||
}
|
||||
ioMock.mockReturnValue(fakeSocket)
|
||||
|
||||
new ApiSessionClient('cli-token', {
|
||||
id: 'session-1',
|
||||
namespace: 'default',
|
||||
seq: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
active: true,
|
||||
activeAt: now,
|
||||
metadata: null,
|
||||
metadataVersion: 0,
|
||||
agentState: null,
|
||||
agentStateVersion: 0,
|
||||
thinking: false,
|
||||
thinkingAt: now,
|
||||
todos: [],
|
||||
model: null,
|
||||
modelReasoningEffort: null,
|
||||
effort: null,
|
||||
permissionMode: undefined,
|
||||
collaborationMode: undefined
|
||||
})
|
||||
|
||||
expect(ioMock).toHaveBeenCalledOnce()
|
||||
expect(ioMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
extraHeaders: {
|
||||
Cookie: 'CF_Authorization=token'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
+5
-4
@@ -6,6 +6,7 @@ import { getAuthToken } from '@/api/auth'
|
||||
import { apiValidationError } from '@/utils/errorUtils'
|
||||
import { ApiMachineClient } from './apiMachine'
|
||||
import { ApiSessionClient } from './apiSession'
|
||||
import { buildHubRequestHeaders } from './hubExtraHeaders'
|
||||
|
||||
export class ApiClient {
|
||||
static async create(): Promise<ApiClient> {
|
||||
@@ -33,10 +34,10 @@ export class ApiClient {
|
||||
effort: opts.effort
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
headers: buildHubRequestHeaders({
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
}),
|
||||
timeout: 60_000
|
||||
}
|
||||
)
|
||||
@@ -96,10 +97,10 @@ export class ApiClient {
|
||||
runnerState: opts.runnerState ?? null
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
headers: buildHubRequestHeaders({
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
}),
|
||||
timeout: 60_000
|
||||
}
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ import { RpcHandlerManager } from './rpc/RpcHandlerManager'
|
||||
import { registerCommonHandlers } from '../modules/common/registerCommonHandlers'
|
||||
import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes'
|
||||
import { applyVersionedAck } from './versionedUpdate'
|
||||
import { buildSocketIoExtraHeaderOptions } from './hubExtraHeaders'
|
||||
|
||||
interface ServerToRunnerEvents {
|
||||
update: (data: Update) => void
|
||||
@@ -231,7 +232,8 @@ export class ApiMachineClient {
|
||||
path: '/socket.io/',
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000
|
||||
reconnectionDelayMax: 5000,
|
||||
...buildSocketIoExtraHeaderOptions()
|
||||
})
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
|
||||
@@ -34,6 +34,7 @@ import { registerCommonHandlers } from '../modules/common/registerCommonHandlers
|
||||
import { cleanupUploadDir } from '../modules/common/handlers/uploads'
|
||||
import { TerminalManager } from '@/terminal/TerminalManager'
|
||||
import { applyVersionedAck } from './versionedUpdate'
|
||||
import { buildHubRequestHeaders, buildSocketIoExtraHeaderOptions } from './hubExtraHeaders'
|
||||
|
||||
/**
|
||||
* XML tags that Claude Code injects as `type:'user'` messages.
|
||||
@@ -118,7 +119,8 @@ export class ApiSessionClient extends EventEmitter {
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
transports: ['websocket'],
|
||||
autoConnect: false
|
||||
autoConnect: false,
|
||||
...buildSocketIoExtraHeaderOptions()
|
||||
})
|
||||
|
||||
this.terminalManager = new TerminalManager({
|
||||
@@ -308,10 +310,10 @@ export class ApiSessionClient extends EventEmitter {
|
||||
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(this.sessionId)}/messages`,
|
||||
{
|
||||
params: { afterSeq: cursor, limit },
|
||||
headers: {
|
||||
headers: buildHubRequestHeaders({
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
}),
|
||||
timeout: 15_000
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { configuration, parseExtraHeaders } from '@/configuration'
|
||||
import { buildHubRequestHeaders, buildSocketIoExtraHeaderOptions } from './hubExtraHeaders'
|
||||
|
||||
describe('parseExtraHeaders', () => {
|
||||
it('parses a JSON object with string values', () => {
|
||||
expect(parseExtraHeaders('{"Cookie":"a=b","X-Test":"1"}')).toEqual({
|
||||
Cookie: 'a=b',
|
||||
'X-Test': '1'
|
||||
})
|
||||
})
|
||||
|
||||
it('drops non-string values', () => {
|
||||
const warn = vi.fn()
|
||||
expect(parseExtraHeaders('{"Cookie":"a=b","X-Num":1,"X-Bool":true}', warn)).toEqual({
|
||||
Cookie: 'a=b'
|
||||
})
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns empty object and warns for invalid json', () => {
|
||||
const warn = vi.fn()
|
||||
expect(parseExtraHeaders('{not-json', warn)).toEqual({})
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns empty object and warns for non-object json', () => {
|
||||
const warn = vi.fn()
|
||||
expect(parseExtraHeaders('["a"]', warn)).toEqual({})
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hub extra headers helpers', () => {
|
||||
beforeEach(() => {
|
||||
configuration._setExtraHeaders({})
|
||||
})
|
||||
|
||||
it('merges custom headers into REST requests without overriding built-in auth headers', () => {
|
||||
configuration._setExtraHeaders({
|
||||
Cookie: 'CF_Authorization=token',
|
||||
Authorization: 'should-not-win'
|
||||
})
|
||||
|
||||
expect(buildHubRequestHeaders({
|
||||
Authorization: 'Bearer cli-token',
|
||||
'Content-Type': 'application/json'
|
||||
})).toEqual({
|
||||
Cookie: 'CF_Authorization=token',
|
||||
Authorization: 'Bearer cli-token',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
})
|
||||
|
||||
it('builds socket transport options when extra headers are configured', () => {
|
||||
configuration._setExtraHeaders({
|
||||
Cookie: 'CF_Authorization=token',
|
||||
'X-Test': '1'
|
||||
})
|
||||
|
||||
expect(buildSocketIoExtraHeaderOptions()).toEqual({
|
||||
extraHeaders: {
|
||||
Cookie: 'CF_Authorization=token',
|
||||
'X-Test': '1'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('returns empty socket options when no extra headers are configured', () => {
|
||||
expect(buildSocketIoExtraHeaderOptions()).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { configuration } from '@/configuration'
|
||||
|
||||
export function buildHubRequestHeaders(baseHeaders: Record<string, string>): Record<string, string> {
|
||||
return {
|
||||
...configuration.extraHeaders,
|
||||
...baseHeaders
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSocketIoExtraHeaderOptions(): {
|
||||
extraHeaders?: Record<string, string>
|
||||
} {
|
||||
if (Object.keys(configuration.extraHeaders).length === 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
extraHeaders: { ...configuration.extraHeaders }
|
||||
}
|
||||
}
|
||||
@@ -100,9 +100,9 @@ export const CreateSessionResponseSchema = z.object({
|
||||
thinking: z.boolean(),
|
||||
thinkingAt: z.number(),
|
||||
todos: TodosSchema.optional(),
|
||||
model: z.string().nullable(),
|
||||
modelReasoningEffort: z.string().nullable(),
|
||||
effort: z.string().nullable(),
|
||||
model: z.string().nullable().optional().default(null),
|
||||
modelReasoningEffort: z.string().nullable().optional().default(null),
|
||||
effort: z.string().nullable().optional().default(null),
|
||||
permissionMode: PermissionModeSchema.optional(),
|
||||
collaborationMode: CodexCollaborationModeSchema.optional()
|
||||
})
|
||||
|
||||
@@ -11,9 +11,38 @@ import { join } from 'node:path'
|
||||
import packageJson from '../package.json'
|
||||
import { getCliArgs } from '@/utils/cliArgs'
|
||||
|
||||
export function parseExtraHeaders(raw: string | undefined, warn: (message: string) => void = console.warn): Record<string, string> {
|
||||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
warn('[WARN] HAPI_EXTRA_HEADERS_JSON must be a JSON object. Ignoring value.')
|
||||
return {}
|
||||
}
|
||||
|
||||
const entries = Object.entries(parsed)
|
||||
const headers = Object.fromEntries(
|
||||
entries.filter((entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string')
|
||||
)
|
||||
|
||||
if (Object.keys(headers).length !== entries.length) {
|
||||
warn('[WARN] HAPI_EXTRA_HEADERS_JSON only supports string header values. Ignoring non-string entries.')
|
||||
}
|
||||
|
||||
return headers
|
||||
} catch {
|
||||
warn('[WARN] Failed to parse HAPI_EXTRA_HEADERS_JSON. Ignoring value.')
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
class Configuration {
|
||||
private _apiUrl: string
|
||||
private _cliApiToken: string
|
||||
private _extraHeaders: Record<string, string>
|
||||
public readonly isRunnerProcess: boolean
|
||||
|
||||
// Directories and paths (from persistence)
|
||||
@@ -31,6 +60,7 @@ class Configuration {
|
||||
// Server configuration
|
||||
this._apiUrl = process.env.HAPI_API_URL || 'http://localhost:3006'
|
||||
this._cliApiToken = process.env.CLI_API_TOKEN || ''
|
||||
this._extraHeaders = parseExtraHeaders(process.env.HAPI_EXTRA_HEADERS_JSON)
|
||||
|
||||
// Check if we're running as runner based on process args
|
||||
const args = getCliArgs()
|
||||
@@ -79,6 +109,14 @@ class Configuration {
|
||||
_setCliApiToken(token: string): void {
|
||||
this._cliApiToken = token
|
||||
}
|
||||
|
||||
get extraHeaders(): Record<string, string> {
|
||||
return this._extraHeaders
|
||||
}
|
||||
|
||||
_setExtraHeaders(headers: Record<string, string>): void {
|
||||
this._extraHeaders = { ...headers }
|
||||
}
|
||||
}
|
||||
|
||||
export const configuration: Configuration = new Configuration()
|
||||
|
||||
Reference in New Issue
Block a user