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:
Shujakuin
2026-04-14 13:54:51 +08:00
committed by GitHub
parent 74377fafee
commit 0bffb03b05
11 changed files with 303 additions and 14 deletions
+1
View File
@@ -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`.
+151
View File
@@ -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
View File
@@ -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
}
)
+3 -1
View File
@@ -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', () => {
+5 -3
View File
@@ -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
}
)
+72
View File
@@ -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({})
})
})
+20
View File
@@ -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 }
}
}
+3 -3
View File
@@ -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()
})
+38
View File
@@ -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()
+2
View File
@@ -177,6 +177,7 @@ On first run, HAPI:
|----------|---------|---------------|-------------|
| `CLI_API_TOKEN` | Auto-generated | `cliApiToken` | Shared secret for authentication |
| `HAPI_API_URL` | `http://localhost:3006` | `apiUrl` | Hub URL for CLI connections |
| `HAPI_EXTRA_HEADERS_JSON` | - | - | JSON object of extra outbound headers for CLI → hub HTTP/WebSocket requests |
| `HAPI_LISTEN_HOST` | `127.0.0.1` | `listenHost` | Hub HTTP bind address |
| `HAPI_LISTEN_PORT` | `3006` | `listenPort` | Hub HTTP port |
| `HAPI_PUBLIC_URL` | - | `publicUrl` | Public URL for external access |
@@ -217,6 +218,7 @@ If the hub is not on localhost, set these before running `hapi`:
```bash
export HAPI_API_URL="http://your-hub:3006"
export CLI_API_TOKEN="your-token-here"
export HAPI_EXTRA_HEADERS_JSON='{"Cookie":"CF_Authorization=..."}'
```
Or use interactive login:
+3 -3
View File
@@ -175,9 +175,9 @@ export const SessionSchema = z.object({
backgroundTaskCount: z.number().optional(),
todos: TodosSchema.optional(),
teamState: TeamStateSchema.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()
})