Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
160 changes: 160 additions & 0 deletions cli/src/api/api.extraHeaders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
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({
transportOptions: {
polling: {
extraHeaders: {
Cookie: 'CF_Authorization=token'
}
},
websocket: {
extraHeaders: {
Cookie: 'CF_Authorization=token'
}
}
}
})
})
})
9 changes: 5 additions & 4 deletions cli/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -33,10 +34,10 @@ export class ApiClient {
effort: opts.effort
},
{
headers: {
headers: buildHubRequestHeaders({
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json'
},
}),
timeout: 60_000
}
)
Expand Down Expand Up @@ -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
}
)
Expand Down
4 changes: 3 additions & 1 deletion cli/src/api/apiMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -231,7 +232,8 @@ export class ApiMachineClient {
path: '/socket.io/',
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000
reconnectionDelayMax: 5000,
...buildSocketIoExtraHeaderOptions()
})

this.socket.on('connect', () => {
Expand Down
8 changes: 5 additions & 3 deletions cli/src/api/apiSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -118,7 +119,8 @@ export class ApiSessionClient extends EventEmitter {
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
transports: ['websocket'],
autoConnect: false
autoConnect: false,
...buildSocketIoExtraHeaderOptions()
})

this.terminalManager = new TerminalManager({
Expand Down Expand Up @@ -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
}
)
Expand Down
80 changes: 80 additions & 0 deletions cli/src/api/hubExtraHeaders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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', () => {
expect(parseExtraHeaders('{"Cookie":"a=b","X-Num":1,"X-Bool":true}')).toEqual({
Cookie: 'a=b'
})
})

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({
transportOptions: {
polling: {
extraHeaders: {
Cookie: 'CF_Authorization=token',
'X-Test': '1'
}
},
websocket: {
extraHeaders: {
Cookie: 'CF_Authorization=token',
'X-Test': '1'
}
}
}
})
})

it('returns empty socket options when no extra headers are configured', () => {
expect(buildSocketIoExtraHeaderOptions()).toEqual({})
})
})
28 changes: 28 additions & 0 deletions cli/src/api/hubExtraHeaders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { configuration } from '@/configuration'

export function buildHubRequestHeaders(baseHeaders: Record<string, string>): Record<string, string> {
return {
...configuration.extraHeaders,
...baseHeaders
}
}

export function buildSocketIoExtraHeaderOptions(): {
transportOptions?: {
polling: { extraHeaders: Record<string, string> }
websocket: { extraHeaders: Record<string, string> }
}
} {
if (Object.keys(configuration.extraHeaders).length === 0) {
return {}
}

const extraHeaders = { ...configuration.extraHeaders }

return {
transportOptions: {
polling: { extraHeaders },
websocket: { extraHeaders }
}
}
}
6 changes: 3 additions & 3 deletions cli/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
Expand Down
Loading
Loading