Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
151 changes: 151 additions & 0 deletions cli/src/api/api.extraHeaders.test.ts
Original file line number Diff line number Diff line change
@@ -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'
}
})
})
})
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
72 changes: 72 additions & 0 deletions cli/src/api/hubExtraHeaders.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 20 additions & 0 deletions cli/src/api/hubExtraHeaders.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
}
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